Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@ The following emojis are used to highlight certain changes:

### Added

- ✨ The web UI now surfaces every check failure as a clear, styled message with a Retry button instead of spinning forever or printing a raw error. It aborts a stalled backend after the requested timeout plus a 5 second leniency, and separately reports an unreachable backend (wrong URL, dead domain, CORS), an HTTP error response (with the backend's message), and a response it cannot parse. Every failure message also links to running your own [self-hosted backend](README.md#self-hosting).
- README documents how to self-host the backend, including the CORS headers it sends and how to keep the UI embeddable in an iframe.

### Changed

- The `/check` endpoint sends liberal CORS headers (`Access-Control-Allow-Origin: *`, plus `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers`) and answers `OPTIONS` preflight, so any frontend or self-hosted deployment can call it cross-origin.

### Removed

### Fixed
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ To modify the web interface styles:
Production deployments should terminate HTTPS at a reverse proxy in front of the Go server.


## Self-hosting

The public backend at `https://ipfs-check-backend.ipfs.io` is shared and rate-limited, so it can be slow or busy. Run your own instance for faster, private checks.

A single `ipfs-check` process serves both the HTTP API (`/check`) and the web UI (`/web/`). Start it (see [Install](#install) or [Docker](#docker)), then either open its own `/web/` page or open <https://check.ipfs.network> and set **Backend URL** under **Backend Config** to your instance (for example `https://ipfs-check.example.com`).

### CORS

The `/check` endpoint sends permissive CORS headers so any web frontend can call it from a different origin, and it answers `OPTIONS` preflight requests:

```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: *
```

Behind a reverse proxy, preserve these headers; do not strip or override `Access-Control-Allow-Origin`.

### Embedding in an iframe

The server does not send `X-Frame-Options`, so the web UI can be embedded in an iframe (IPFS WebUI's Diagnostics page does this). Behind a reverse proxy, keep it embeddable by not adding `X-Frame-Options` or a restrictive `Content-Security-Policy: frame-ancestors` directive.


## Running locally

### Terminal 1
Expand Down
19 changes: 18 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,13 @@ func startServer(ctx context.Context, d *daemon, tcpListener, metricsUsername, m
log.Printf("Ready to start serving.")

checkHandler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Access-Control-Allow-Origin", "*")
setCORSHeaders(w)
// Answer CORS preflight so browsers on any origin, including pages that
// embed the UI in an iframe, can call this endpoint.
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}

q := r.URL.Query()
maStr := q.Get("multiaddr")
Expand Down Expand Up @@ -293,6 +299,17 @@ func BasicAuth(handler http.Handler, username, password string) http.Handler {
})
}

// setCORSHeaders sets permissive CORS headers on the check endpoint so it can
// be called from any web frontend: the hosted UI at check.ipfs.network, pages
// that embed the UI in an iframe, and self-hosted deployments served from a
// different origin.
func setCORSHeaders(w http.ResponseWriter) {
h := w.Header()
h.Set("Access-Control-Allow-Origin", "*")
h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
h.Set("Access-Control-Allow-Headers", "*")
}

// getWebAddress returns listener with [::] and 0.0.0.0 replaced by localhost
func getWebAddress(l net.Listener) string {
addr := l.Addr().String()
Expand Down
2 changes: 1 addition & 1 deletion web/output.css

Large diffs are not rendered by default.

184 changes: 158 additions & 26 deletions web/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ const iconCheck = `<svg class="inline w-5 h-5 text-green-500 mr-1" fill="none" s
const iconCross = `<svg class="inline w-5 h-5 text-red-500 mr-1" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>`
const iconInfo = `<svg class="inline w-5 h-5 text-blue-500 mr-1" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01"/></svg>`

// Link to the ipfs-check backend repo, surfaced when the configured backend is
// slow or unreachable so users can run their own and point Backend URL at it.
const selfHostedBackendLink = `<a href='https://github.com/ipfs/ipfs-check#self-hosting' target='_blank' rel='noopener noreferrer' class='text-blue-600 hover:text-blue-800 underline'>self-hosted ipfs-check backend</a>`

// Appended to every failure box so the self-hosting option is always one click
// away, whatever the backend problem was.
const selfHostTip = `<span class='block mt-2 text-sm'>Tip: you can run a ${selfHostedBackendLink} and use it as your <b>Backend URL</b>.</span>`

window.addEventListener('load', function () {
initFormValues(new URL(window.location))

Expand Down Expand Up @@ -30,6 +38,14 @@ window.addEventListener('load', function () {
cidInput.addEventListener('change', function() {
showOutput('') // clear out previous results
showRawOutput('') // clear out previous results
// Clearing results returns us to a fresh state, so drop any
// leftover "Retry" label from a prior timeout. Skip while a check
// is running (button disabled) to leave the live countdown intact.
const button = document.getElementById('submit')
const buttonText = document.getElementById('button-text')
if (button && buttonText && !button.disabled) {
buttonText.textContent = 'Run Test'
}
})
}

Expand All @@ -42,9 +58,14 @@ window.addEventListener('load', function () {
const formData = new FormData(queryForm)
const backendURL = getBackendUrl(formData)
const inputMaddr = formData.get('multiaddr')

// Start countdown timer
const timeoutSeconds = parseInt(formData.get('timeoutSeconds')) || 30
// The backend bounds its own work by timeoutSeconds. Give it that long
// plus a small leniency for network and serialization overhead, then
// abort the request. Without this the fetch hangs indefinitely when the
// backend stalls past its deadline (e.g. overloaded or unreachable).
const abortAfterSeconds = timeoutSeconds + 5
startCountdown(timeoutSeconds)

plausible('IPFS Check Run', {
Expand All @@ -55,66 +76,106 @@ window.addEventListener('load', function () {

showInQuery(formData) // add `cid` and `multiaddr` to local url query to make it shareable
toggleSubmitButton()

const controller = new AbortController()
const abortTimer = setTimeout(() => controller.abort(), abortAfterSeconds * 1000)
let failed = false
// Set once fetch resolves, to tell connect failures (bad URL, dead
// domain, CORS, offline) apart from failures reading the response.
let reached = false
try {
const res = await fetch(backendURL, { method: 'POST' })
const res = await fetch(backendURL, { method: 'POST', signal: controller.signal })
reached = true

if (res.ok) {
const respObj = await res.json()
showRawOutput(JSON.stringify(respObj, null, 2))

if(inputMaddr === '') {
const output = formatJustCidOutput(respObj)
showOutput(output)
} else {
const output = formatMaddrOutput(inputMaddr, respObj)
showOutput(output)
// Rendering is separate from reading: a formatter throwing on an
// unexpected (but valid) response shape is a display bug, not a
// backend failure, so report it honestly and leave the button as
// Run Test (a retry would fail the same way). The raw JSON above
// stays available.
try {
if(inputMaddr === '') {
const output = formatJustCidOutput(respObj)
showOutput(output)
} else {
const output = formatMaddrOutput(inputMaddr, respObj)
showOutput(output)
}
} catch (renderErr) {
console.log(renderErr)
showOutput(formatRenderErrorOutput(renderErr))
}
} else {
failed = true
const resText = await res.text()
showOutput(`⚠️ backend returned an error: ${res.status} ${resText}`)
showOutput(formatHttpErrorOutput(backendURL.host, res.status, resText))
}
} catch (e) {
console.log(e)
showOutput(`⚠️ backend error: ${e}`)
failed = true
if (e.name === 'AbortError') {
showOutput(formatTimeoutOutput(timeoutSeconds, abortAfterSeconds, backendURL.host))
} else {
console.log(e)
showOutput(formatRequestErrorOutput(backendURL.host, reached, e))
}
} finally {
stopCountdown()
clearTimeout(abortTimer)
// Any failure relabels the button to Retry so another click re-runs
// the same check (the button is a form submit, so the click re-runs
// this handler). A successful run restores the default label.
stopCountdown(failed ? 'Retry' : 'Run Test')
toggleSubmitButton()
}
})

function startCountdown(seconds) {
// Clear any existing countdown
stopCountdown()
// Clear any existing timer without touching the label; it is set below.
clearCountdownTimer()

const buttonText = document.getElementById('button-text')
let remaining = seconds

// Update button text immediately
if (buttonText) {
buttonText.textContent = `Testing: ${remaining}s`
}

// Update every second
countdownInterval = setInterval(() => {
remaining--
if (remaining <= 0) {
stopCountdown()
} else if (buttonText) {
buttonText.textContent = `Testing: ${remaining}s`
if (remaining > 0) {
if (buttonText) {
buttonText.textContent = `Testing: ${remaining}s`
}
} else {
// Past the requested timeout. The request is still in flight
// during the leniency window before it is aborted, so keep the
// button in a busy state rather than resetting its label.
clearCountdownTimer()
if (buttonText) {
buttonText.textContent = 'Waiting…'
}
}
}, 1000)
}
function stopCountdown() {

function clearCountdownTimer() {
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}

// Restore original button text
}

function stopCountdown(label = 'Run Test') {
clearCountdownTimer()

// Restore the button label (default 'Run Test', or 'Retry' on abort).
const buttonText = document.getElementById('button-text')
if (buttonText) {
buttonText.textContent = 'Run Test'
buttonText.textContent = label
}
}
})
Expand Down Expand Up @@ -202,6 +263,77 @@ function toggleSubmitButton() {
}
}

// htmlEscapes is hoisted so escapeHtml builds the lookup once, not per char.
const htmlEscapes = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }

// escapeHtml makes a backend-supplied string safe to interpolate as HTML text
// or into a quoted attribute. Error bodies and exception messages can carry
// markup (e.g. a JSON parse error quotes the offending "<html>..." page), which
// would otherwise render as HTML.
function escapeHtml (s) {
return String(s).replace(/[&<>"']/g, (c) => htmlEscapes[c])
}

// codeSnippet renders a backend-supplied value as escaped inline <code>.
function codeSnippet (text) {
return `<code class='bg-gray-100 px-1 rounded'>${escapeHtml(text)}</code>`
}

// errText extracts a displayable string from a thrown value.
function errText (err) {
return String((err && err.message) || err || 'unknown error')
}

// backendLabel returns " <code>host</code>" (note the leading space) to drop
// into a "the backend<label>" sentence, or '' when the host is unknown.
function backendLabel (host) {
return host ? ` ${codeSnippet(host)}` : ''
}

// failureBox renders a styled message box for a failed check, always appending
// the self-hosting tip. warn=true uses the softer yellow style (timeouts);
// other failures use red.
function failureBox (message, warn) {
const style = warn
? 'bg-yellow-100 border-l-4 border-yellow-500 text-yellow-800'
: 'bg-red-100 border-l-4 border-red-500 text-red-700'
return `<div class='${style} p-4 rounded mb-4 flex gap-x-2 items-start'>${warn ? iconInfo : iconCross}<span>${message}${selfHostTip}</span></div>`
}

function formatTimeoutOutput (timeoutSeconds, abortAfterSeconds, backendHost) {
return failureBox(`The backend${backendLabel(backendHost)} did not finish responding within <b>${abortAfterSeconds}s</b> (your ${timeoutSeconds}s timeout plus 5s leniency), so the request was aborted. It may be overloaded or unreachable. Press <b>Retry</b> to run the check again, or raise the <b>Check Timeout</b> in <b>Backend Config</b>.`, true)
}

function formatHttpErrorOutput (backendHost, status, body) {
const trimmed = (body || '').trim()
const detail = trimmed ? `: ${codeSnippet(trimmed)}` : ''
// 4xx is a client error: retrying the same request fails the same way, so
// steer the user to fix their input. 5xx and the rest are transient.
const action = status >= 400 && status < 500
? 'Check your input, then press <b>Retry</b>.'
: 'Press <b>Retry</b> to run the check again.'
return failureBox(`The backend${backendLabel(backendHost)} returned an error (HTTP <b>${status}</b>)${detail}. ${action}`)
}

// formatRenderErrorOutput covers a response that was received and parsed but
// could not be rendered (an unexpected shape made a formatter throw). It is a
// display issue rather than a backend failure, so it points at the raw JSON
// instead of suggesting a retry.
function formatRenderErrorOutput (err) {
return failureBox(`The check ran, but its response could not be displayed (${codeSnippet(errText(err))}). See <b>Raw Output</b> below for the full response.`)
}

// formatRequestErrorOutput covers a fetch that never produced a response
// (reached=false: bad URL, dead domain, DNS failure, connection refused, CORS,
// TLS, offline) and a response that was received but could not be read
// (reached=true, e.g. the body was not valid JSON).
function formatRequestErrorOutput (backendHost, reached, err) {
if (!reached) {
return failureBox(`Could not reach the backend${backendLabel(backendHost)}. The address may be wrong, the server may be down, or it may be blocking cross-origin (CORS) requests. Check the <b>Backend URL</b> in <b>Backend Config</b>, then press <b>Retry</b>.`)
}
return failureBox(`The backend${backendLabel(backendHost)} returned a response that could not be read (${codeSnippet(errText(err))}). Press <b>Retry</b> to run the check again.`)
}

function formatMutableResolution(mutableRes) {
if (!mutableRes) return ''

Expand Down
Loading