Skip to content

Commit 6151c1d

Browse files
authored
feat: add hotlink column, cross-domain redirect detection (#661)
- add hotlink column to test image embedding via <img src> - detect cross-domain redirects with status indicator - hotlink runs first for fast visual feedback - redirect detection runs once per gateway, shared by all tests - improved column tooltips - use CSS Grid for column alignment across rows - add mobile responsive breakpoints for gateway results table
1 parent 2cba3d6 commit 6151c1d

17 files changed

+613
-143
lines changed

scripts/test-gateways.mjs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ function trim (str) {
4444
return str.length > 7 ? str.slice(0, 7) + '...' : str
4545
}
4646

47-
(async () => {
47+
const shouldPrune = process.argv.includes('--prune')
48+
49+
;(async () => {
4850
const gateways = JSON.parse(await fs.readFile('./gateways.json'))
4951
const resolvableGateways = []
5052

@@ -81,5 +83,8 @@ function trim (str) {
8183
}
8284
}
8385

84-
await fs.writeFile('./gateways.json', JSON.stringify(resolvableGateways, null, ' '))
86+
if (shouldPrune) {
87+
await fs.writeFile('./gateways.json', JSON.stringify(resolvableGateways, null, ' '))
88+
console.log(`\nPruned gateways.json: ${gateways.length}${resolvableGateways.length}`)
89+
}
8590
})()

src/Cors.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fetchPonyfill from 'fetch-ponyfill'
22
import { CheckBase } from './CheckBase.js'
33
import { Log } from './Log.js'
4+
import { checkCrossDomainRedirect } from './checkCrossDomainRedirect.js'
45
import { HASH_STRING, HASH_TO_TEST } from './constants.js'
56
import type { GatewayNode } from './GatewayNode.js'
67
import type { Checkable } from './types.js'
@@ -20,25 +21,51 @@ class Cors extends CheckBase implements Checkable {
2021
const now = Date.now()
2122
const gatewayAndHash = `${this.parent.gateway}/ipfs/${HASH_TO_TEST}`
2223
const testUrl = `${gatewayAndHash}?now=${now}#x-ipfs-companion-no-redirect`
24+
25+
// Use shared redirect detection result from parent
26+
const { confirmedTarget, possibleRedirect, errorStatus } = this.parent.redirectDetection
27+
let redirectTarget = confirmedTarget
28+
2329
// response body can be accessed only if fetch was executed when
2430
// liberal CORS is present (eg. '*')
2531
try {
2632
const response = await fetch(testUrl)
2733
const { status } = response
34+
// Also check response.url for redirects that CORS allows
35+
const redirect = checkCrossDomainRedirect(response.url, this.parent.gateway)
36+
if (redirect != null) {
37+
redirectTarget = redirect
38+
this.parent.crossDomainRedirect = redirect
39+
}
2840
const text = await response.text()
29-
this.tag.title = `Response code: ${status}`
41+
this.tag.title = redirectTarget != null
42+
? `Response code: ${status}, redirected to ${redirectTarget}`
43+
: `Response code: ${status}`
3044
if (HASH_STRING === text.trim()) {
31-
// this.parent.checked()
32-
this.tag.asterisk()
45+
this.tag.win()
3346
this.parent.tag.classList.add('cors')
34-
} else {
35-
log.debug('The response text did not match the expected string')
36-
this.onerror()
37-
throw new Error(`URL '${testUrl} does not support CORS`)
47+
return
3848
}
49+
// Content mismatch - test failed, don't throw (catch is for fetch failures)
50+
log.debug('The response text did not match the expected string')
51+
this.onerror()
3952
} catch (err) {
53+
// Only reaches here if fetch itself failed (network/CORS error)
4054
log.error(err)
41-
this.onerror()
55+
// Check detection results in priority order (redirect takes precedence over error)
56+
if (redirectTarget != null) {
57+
this.tag.redirect()
58+
this.tag.title = `Redirected to ${redirectTarget}`
59+
} else if (possibleRedirect) {
60+
this.parent.crossDomainRedirect = 'another gateway'
61+
this.tag.redirect()
62+
this.tag.title = 'HTTP redirect detected'
63+
} else if (errorStatus != null) {
64+
this.tag.lose()
65+
this.tag.title = `HTTP ${errorStatus} error`
66+
} else {
67+
this.onerror()
68+
}
4269
throw err
4370
}
4471
}
@@ -48,7 +75,8 @@ class Cors extends CheckBase implements Checkable {
4875
}
4976

5077
onerror (): void {
51-
this.tag.empty()
78+
this.tag.lose()
79+
this.tag.title = 'CORS fetch failed or content mismatch'
5280
}
5381
}
5482

src/GatewayNode.ts

Lines changed: 62 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
import { URL } from 'url-ponyfill'
22
import { Cors } from './Cors.js'
33
import { Flag } from './Flag.js'
4+
import { Hotlink } from './Hotlink.js'
45
import { IPNSCheck } from './Ipns.js'
56
import { Log } from './Log.js'
67
import { Origin } from './Origin.js'
78
import { Status } from './Status.js'
89
import { Trustless } from './Trustless.js'
910
import { UiComponent } from './UiComponent.js'
10-
import { HASH_TO_TEST } from './constants.js'
11+
import { HASH_TO_TEST, SLOW_TESTS_DELAY } from './constants.js'
12+
import { detectCrossDomainRedirect } from './detectCrossDomainRedirect.js'
1113
import { gatewayHostname } from './gatewayHostname.js'
1214
import type { Results } from './Results.js'
15+
import type { RedirectDetectionResult } from './detectCrossDomainRedirect.js'
1316

1417
const log = new Log('GatewayNode')
1518

1619
class GatewayNode extends UiComponent /* implements Checkable */ {
1720
// tag: Tag
1821
status: Status
22+
hotlink: Hotlink
1923
cors: Cors
2024
ipns: IPNSCheck
2125
origin: Origin
@@ -28,6 +32,9 @@ class GatewayNode extends UiComponent /* implements Checkable */ {
2832
checkingTime: number
2933

3034
atLeastOneSuccess = false
35+
crossDomainRedirect: string | null = null
36+
// Shared redirect detection result (run once, used by all tests)
37+
redirectDetection: RedirectDetectionResult = { confirmedTarget: null, possibleRedirect: false, errorStatus: null }
3138

3239
constructor (readonly parent: Results, gateway: string, index: unknown) {
3340
super(parent, 'div', 'Node')
@@ -39,6 +46,9 @@ class GatewayNode extends UiComponent /* implements Checkable */ {
3946
this.status = new Status(this)
4047
this.tag.append(this.status.tag)
4148

49+
this.hotlink = new Hotlink(this)
50+
this.tag.append(this.hotlink.tag)
51+
4252
this.cors = new Cors(this)
4353
this.tag.append(this.cors.tag)
4454

@@ -73,42 +83,54 @@ class GatewayNode extends UiComponent /* implements Checkable */ {
7383

7484
public async check (): Promise<void> {
7585
this.checkingTime = Date.now()
76-
// const onFailedCheck = () => { this.status.down = true }
77-
// const onSuccessfulCheck = () => { this.status.up = true }
86+
const onSuccess = this.onSuccessfulCheck.bind(this)
87+
7888
void this.flag.check().then(() => { log.debug(this.gateway, 'Flag success') })
79-
const onlineChecks = [
80-
// this.flag.check().then(() => log.debug(this.gateway, 'Flag success')),
81-
this.status.check().then(() => { log.debug(this.gateway, 'Status success') }).then(this.onSuccessfulCheck.bind(this)),
82-
this.cors.check().then(() => { log.debug(this.gateway, 'CORS success') }).then(this.onSuccessfulCheck.bind(this)),
83-
this.ipns.check().then(() => { log.debug(this.gateway, 'IPNS success') }).then(this.onSuccessfulCheck.bind(this)),
84-
this.origin.check().then(() => { log.debug(this.gateway, 'Origin success') }).then(this.onSuccessfulCheck.bind(this)),
85-
this.trustless.check().then(
86-
() => { log.debug(this.gateway, 'Trustless success') }).then(this.onSuccessfulCheck.bind(this))
87-
]
88-
89-
// we care only about the fastest method to return a success
90-
// Promise.race(onlineChecks).catch((err) => {
91-
// log.error('Promise race error', err)
92-
// })
93-
94-
// await Promise.all(onlineChecks).catch(onFailedCheck)
95-
await Promise.allSettled(onlineChecks).then((results) => results.map((result) => {
96-
return result.status
97-
})).then((statusArray) => {
98-
if (statusArray.includes('fulfilled')) {
99-
// At least promise was successful, which means the gateway is online
100-
log.debug(`For gateway '${this.gateway}', at least one promise was successfully fulfilled`)
101-
log.debug(this.gateway, 'this.status.up: ', this.status.up)
102-
log.debug(this.gateway, 'this.status.down: ', this.status.down)
103-
this.status.up = true
104-
log.debug(this.gateway, 'this.status.up: ', this.status.up)
105-
log.debug(this.gateway, 'this.status.down: ', this.status.down)
106-
} else {
107-
// No promise was successful, the gateway is down.
108-
this.status.down = true
109-
log.debug(`For gateway '${this.gateway}', all promises were rejected`)
110-
}
89+
90+
// Start Hotlink immediately (fastest, lightest test - browser-native img loading)
91+
const hotlinkPromise = this.hotlink.check()
92+
.then(() => { log.debug(this.gateway, 'Hotlink success') })
93+
.then(onSuccess)
94+
.catch(() => { log.debug(this.gateway, 'Hotlink failed') })
95+
96+
// Delay other tests to give Hotlink exclusive network access
97+
const delayedTestsPromise = new Promise<void>((resolve) => {
98+
setTimeout(() => {
99+
// Run redirect detection once, share result with all tests
100+
const testUrl = `${this.gateway}/ipfs/${HASH_TO_TEST}?now=${Date.now()}#x-ipfs-companion-no-redirect`
101+
void detectCrossDomainRedirect(testUrl, this.gateway).then((result) => {
102+
this.redirectDetection = result
103+
if (result.confirmedTarget != null) {
104+
this.crossDomainRedirect = result.confirmedTarget
105+
}
106+
// Now start all tests (they use this.redirectDetection)
107+
const otherTests = [
108+
this.cors.check()
109+
.then(() => { log.debug(this.gateway, 'CORS success') })
110+
.then(onSuccess),
111+
this.ipns.check()
112+
.then(() => { log.debug(this.gateway, 'IPNS success') })
113+
.then(onSuccess),
114+
this.origin.check()
115+
.then(() => { log.debug(this.gateway, 'Origin success') })
116+
.then(onSuccess),
117+
this.trustless.check()
118+
.then(() => { log.debug(this.gateway, 'Trustless success') })
119+
.then(onSuccess)
120+
]
121+
void Promise.allSettled(otherTests).then(() => { resolve() })
122+
})
123+
}, SLOW_TESTS_DELAY)
111124
})
125+
126+
// Wait for both Hotlink and delayed tests to complete
127+
await Promise.allSettled([hotlinkPromise, delayedTestsPromise])
128+
129+
// Set status.down only if ALL tests failed
130+
if (!this.atLeastOneSuccess) {
131+
this.status.down = true
132+
log.debug(`Gateway '${this.gateway}' - all tests failed`)
133+
}
112134
}
113135

114136
private onSuccessfulCheck (): void {
@@ -119,21 +141,18 @@ class GatewayNode extends UiComponent /* implements Checkable */ {
119141
const url = this.link.url
120142
if (url != null) {
121143
const host = gatewayHostname(url)
122-
// const anchor = document.createElement('a')
123-
// anchor.title = host
124-
// anchor.href = `${url.toString()}#x-ipfs-companion-no-redirect`
125-
// anchor.target = '_blank'
126-
// anchor.textContent = host
127-
// this.flag.tag.element.remove()
128-
// this.link.textContent = ''
129-
// this.link.append(this.flag.tag.element, anchor)
130144
this.link.innerHTML = `<a title="${host}" href="${url.toString()}#x-ipfs-companion-no-redirect" target="_blank">${host}</a>`
131145
}
132146
const ms = Date.now() - this.checkingTime
133147
this.tag.style.order = ms.toString()
134148
const s = (ms / 1000).toFixed(2)
135149
this.took.textContent = `${s}s`
136150
}
151+
// Update status if a later test detected a redirect
152+
// (e.g., Hotlink succeeded first, then CORS detected redirect)
153+
if (this.crossDomainRedirect != null) {
154+
this.status.updateForRedirect()
155+
}
137156
}
138157

139158
// private checked () {

src/Hotlink.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { URL } from 'url-ponyfill'
2+
import { CheckBase } from './CheckBase.js'
3+
import { Log } from './Log.js'
4+
import { checkViaImgSrc } from './checkViaImgSrc.js'
5+
import { IMG_HASH } from './constants.js'
6+
import type { GatewayNode } from './GatewayNode.js'
7+
import type { Checkable } from './types.js'
8+
9+
const log = new Log('Hotlink')
10+
11+
class Hotlink extends CheckBase implements Checkable {
12+
_className = 'Hotlink'
13+
_tagName = 'div'
14+
constructor (protected parent: GatewayNode) {
15+
super(parent, 'div', 'Hotlink')
16+
}
17+
18+
// Hotlink test is optimized for speed - no redirect detection.
19+
// Other tests (CORS, IPNS, etc.) detect redirects and update Status.
20+
async check (): Promise<void> {
21+
const gwUrl = new URL(this.parent.gateway)
22+
const imgPathUrl = new URL(`${gwUrl.protocol}//${gwUrl.hostname}/ipfs/${IMG_HASH}?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`)
23+
24+
try {
25+
await checkViaImgSrc(imgPathUrl)
26+
this.tag.win()
27+
} catch (err) {
28+
log.error(err)
29+
this.onerror()
30+
throw err
31+
}
32+
}
33+
34+
checked (): void {
35+
log.warn('Not implemented yet')
36+
}
37+
38+
onerror (): void {
39+
this.tag.lose()
40+
this.tag.title = 'Failed to load image via path gateway'
41+
}
42+
}
43+
44+
export { Hotlink }

src/Ipns.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fetchPonyfill from 'fetch-ponyfill'
22
import { CheckBase } from './CheckBase.js'
33
import { Log } from './Log.js'
4+
import { checkCrossDomainRedirect } from './checkCrossDomainRedirect.js'
45
import { IPNS_PATH_TO_TEST } from './constants.js'
56
import type { GatewayNode } from './GatewayNode.js'
67
import type { Checkable } from './types.js'
@@ -22,17 +23,48 @@ class IPNSCheck extends CheckBase implements Checkable {
2223
const gatewayUrl = new URL(this.parent.gateway)
2324
gatewayUrl.pathname = IPNS_PATH_TO_TEST
2425
const testUrl = `${gatewayUrl.href}?now=${now}#x-ipfs-companion-no-redirect`
26+
27+
// Use shared redirect detection result from parent
28+
const { confirmedTarget, possibleRedirect, errorStatus } = this.parent.redirectDetection
29+
let redirectTarget = confirmedTarget
30+
2531
try {
2632
const response = await fetch(testUrl)
33+
// Also check response.url for redirects that CORS allows
34+
const redirect = checkCrossDomainRedirect(response.url, this.parent.gateway)
35+
if (redirect != null) {
36+
redirectTarget = redirect
37+
this.parent.crossDomainRedirect = redirect
38+
}
2739
if (response.status === 200) {
28-
this.tag.win()
29-
} else {
30-
log.debug(`${this.parent.gateway} does not support IPNS`)
31-
throw new Error(`URL '${testUrl} is not reachable`)
40+
if (redirectTarget != null) {
41+
this.tag.redirect()
42+
this.tag.title = `Redirected to ${redirectTarget}`
43+
} else {
44+
this.tag.win()
45+
}
46+
return
3247
}
48+
// Non-200 status - test failed, don't throw (catch is for fetch failures)
49+
log.debug(`${this.parent.gateway} does not support IPNS`)
50+
this.onerror()
3351
} catch (err) {
52+
// Only reaches here if fetch itself failed (network/CORS error)
3453
log.error(err)
35-
this.onerror()
54+
// Check detection results in priority order (redirect takes precedence over error)
55+
if (redirectTarget != null) {
56+
this.tag.redirect()
57+
this.tag.title = `Redirected to ${redirectTarget}`
58+
} else if (possibleRedirect) {
59+
this.parent.crossDomainRedirect = 'another gateway'
60+
this.tag.redirect()
61+
this.tag.title = 'HTTP redirect detected'
62+
} else if (errorStatus != null) {
63+
this.tag.lose()
64+
this.tag.title = `HTTP ${errorStatus} error`
65+
} else {
66+
this.onerror()
67+
}
3668
throw err
3769
}
3870
}
@@ -42,7 +74,8 @@ class IPNSCheck extends CheckBase implements Checkable {
4274
}
4375

4476
onerror (): void {
45-
this.tag.err()
77+
this.tag.lose()
78+
this.tag.title = 'IPNS resolution failed (non-200 response)'
4679
}
4780
}
4881

0 commit comments

Comments
 (0)