Skip to content

Commit e8c5a42

Browse files
authored
Merge pull request #594 from kleros/fix/ipfs-uri-handling
fix: centralize IPFS URI handling and improve logic
2 parents ccd3155 + ce55ba7 commit e8c5a42

9 files changed

Lines changed: 151 additions & 37 deletions

File tree

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"scripts": {
1717
"prestart": "run-s build:theme",
1818
"start": "cross-env BROWSER=none react-app-rewired start",
19+
"test": "react-app-rewired test --watchAll=false",
1920
"prebuild": "run-s build:theme",
2021
"build": "react-app-rewired build",
2122
"build:analyze": "source-map-explorer build/static/js/main.*",
@@ -104,7 +105,8 @@
104105
"web3": "1.5.2"
105106
},
106107
"resolutions": {
107-
"html-to-text": "^7.0.0"
108+
"html-to-text": "^7.0.0",
109+
"graceful-fs": "^4.2.11"
108110
},
109111
"volta": {
110112
"node": "16.20.2",

src/bootstrap/dataloader.js

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,25 @@ import axios from "axios";
55
import useSWR from "swr";
66
import arbitrableWhitelist from "../temp/arbitrable-whitelist";
77
import { displaySubgraph } from "./subgraph";
8+
import { isContentAddressed, toHttpUrl } from "../utils/ipfs";
89

910
const getURIProtocol = (uri) => {
1011
const uriParts = uri.replace(":", "").split("/");
1112
return uri.startsWith("/") ? uriParts[1] : uriParts[0];
1213
};
1314

1415
const getHttpUri = (uri) => {
16+
if (isContentAddressed(uri)) return toHttpUrl(uri);
17+
1518
const protocol = getURIProtocol(uri);
1619
switch (protocol) {
1720
case "http":
1821
case "https":
1922
case "ipns":
20-
break;
21-
case "fs":
22-
if (uri.includes("/ipfs/")) uri = uri.split(":/").pop();
23-
else throw new Error(`Unrecognized protocol ${protocol}`);
24-
break;
25-
case "ipfs":
26-
uri = uri.replace("://", ":/");
27-
if (uri.substr(0, 5) === "/ipfs" || uri.substr(0, 5) === "ipfs/") {
28-
if (uri.substr(0, 1) === "/") uri = uri.substr(1, uri.length - 1);
29-
uri = `https://cdn.kleros.link/${uri}`;
30-
} else if (uri.substr(0, 6) === "ipfs:/") uri = `https://cdn.kleros.link/${uri.split(":/").pop()}`;
31-
else throw new Error(`Unrecognized protocol ${protocol}`);
32-
break;
23+
return uri;
3324
default:
3425
throw new Error(`Unrecognized protocol ${protocol}`);
3526
}
36-
37-
return uri;
3827
};
3928

4029
const fetchDataFromScript = async (scriptString, scriptParameters) => {
@@ -129,6 +118,7 @@ const funcs = {
129118

130119
const uri = metaEvidenceUriData.data?.metaEvidenceUri;
131120
if (!uri) throw new Error(`No MetaEvidence log for disputeId ${disputeId} on chainID ${chainID}`);
121+
if (!isContentAddressed(uri)) break;
132122

133123
let metaEvidenceJSON = (await axios.get(getHttpUri(uri))).data;
134124

@@ -149,6 +139,8 @@ const funcs = {
149139
metaEvidenceJSON.rulingOptions.type = "single-select";
150140

151141
if (metaEvidenceJSON.dynamicScriptURI) {
142+
if (!isContentAddressed(metaEvidenceJSON.dynamicScriptURI)) break;
143+
152144
const scriptURI =
153145
chainID === 1 && disputeId === "1621"
154146
? getHttpUri("/ipfs/Qmf1k727vP7qZv21MDB8vwL6tfVEKPCUQAiw8CTfHStkjf")
@@ -209,17 +201,19 @@ const funcs = {
209201
description:
210202
"In case you have an AdBlock enabled, please disable it and refresh the page. It may be preventing the correct working of the page. If that's not the case, the data for this case is not formatted correctly or has been tampered since the time of its submission. Please refresh the page and refuse to arbitrate if the problem persists.",
211203
title: "Invalid or tampered case data, refuse to arbitrate.",
204+
rulingOptions: { type: "single-select", titles: [] },
212205
};
213206
},
214207
async loadPolicy(URI) {
215208
if (!URI) {
216209
console.error("No URI provided");
217210
return;
218211
}
219-
const prefix = URI.startsWith("/ipfs/") ? "" : "/ipfs/";
220-
const policyURL = `https://cdn.kleros.link${prefix}${URI}`;
212+
const policyURL = toHttpUrl(URI);
221213

222214
try {
215+
if (!isContentAddressed(URI)) throw new Error(`Policy URI is not content-addressed: ${URI}`);
216+
223217
const res = await axios.get(policyURL);
224218

225219
if (res.status !== 200)
@@ -311,6 +305,9 @@ const evidenceFetcher = async ([subgraph, disputeId]) => {
311305
await Promise.all(
312306
evidence.map(async (evidenceItem) => {
313307
try {
308+
if (!isContentAddressed(evidenceItem.URI))
309+
throw new Error(`Evidence URI is not content-addressed: ${evidenceItem.URI}`);
310+
314311
const uri = getHttpUri(evidenceItem.URI);
315312
try {
316313
const fileRes = await axios.get(uri);

src/components/attachment.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ReactComponent as Link } from "../assets/images/link.svg";
1111
import { ReactComponent as PDF } from "../assets/images/pdf.svg";
1212
import { ReactComponent as Video } from "../assets/images/video.svg";
1313
import { isSafeNavigationUrl } from "../utils/urlValidation";
14+
import { isContentAddressed, toHttpUrl } from "../utils/ipfs";
1415

1516
const StyledPopover = styled(({ className, ...rest }) => (
1617
<Popover className={className} overlayClassName={className} {...rest} />
@@ -51,11 +52,11 @@ const Attachment = ({ URI, description, extension: _extension, previewURI, title
5152
else Component = Link;
5253
Component = <Component className="primary-purple-fill theme-fill" />;
5354

54-
const href = URI.replace(/^\/ipfs\//, "https://cdn.kleros.link/ipfs/");
55+
const href = toHttpUrl(URI);
5556

56-
// Unsafe attachment URL still indicate that a file was attached,
57-
// but render it as a disabled, non-clickable icon with an explanation.
58-
if (!isSafeNavigationUrl(href)) {
57+
//A rejected attachment still indicates that a file was attached,
58+
//but renders as a disabled, non-clickable icon with an explanation.
59+
if (!isContentAddressed(URI) || !isSafeNavigationUrl(href)) {
5960
return (
6061
<Tooltip
6162
overlayStyle={{ wordBreak: "break-all" }}

src/components/case-details-card.jsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import useGetDraws from "../hooks/use-get-draws";
3030
import arbitrableWhitelist from "../temp/arbitrable-whitelist";
3131
import { getAnswerString, RTA_LABEL } from "../temp/answer-string";
3232
import { isSafeNavigationUrl } from "../utils/urlValidation";
33+
import { toHttpUrl } from "../utils/ipfs";
3334

3435
const { useDrizzle, useDrizzleState } = drizzleReactHooks;
3536
const { toBN, soliditySha3 } = Web3.utils;
@@ -494,7 +495,6 @@ export default function CaseDetailsCard({ ID }) {
494495
}, [dispute?.arbitrated, arbitrableChainID, metaEvidence]);
495496

496497
const evidenceDisplayInterfaceURL = useMemo(() => {
497-
const normalizeIPFSUri = (uri) => uri.replace(/^\/ipfs\//, "https://cdn.kleros.link/ipfs/");
498498
if (metaEvidence?.evidenceDisplayInterfaceURI) {
499499
// hack to allow displaying old t2cr disputes, since old endpoint was lost
500500
const evidenceDisplayInterfaceURI =
@@ -504,7 +504,7 @@ export default function CaseDetailsCard({ ID }) {
504504

505505
const { _v = "0" } = metaEvidence;
506506

507-
let url = normalizeIPFSUri(evidenceDisplayInterfaceURI);
507+
let url = toHttpUrl(evidenceDisplayInterfaceURI);
508508
if (!isSafeNavigationUrl(url)) return null;
509509

510510
const injectedParams = {
@@ -529,6 +529,12 @@ export default function CaseDetailsCard({ ID }) {
529529
}
530530
}, [metaEvidence, ID, dispute, chainId, KlerosLiquid.address, arbitratorChainID, arbitrableChainID]);
531531

532+
const arbitrableInterfaceURL = useMemo(() => {
533+
if (!metaEvidence?.arbitrableInterfaceURI) return null;
534+
const url = toHttpUrl(metaEvidence.arbitrableInterfaceURI);
535+
return isSafeNavigationUrl(url) ? url : null;
536+
}, [metaEvidence]);
537+
532538
return (
533539
<>
534540
<StyledCard
@@ -705,9 +711,9 @@ export default function CaseDetailsCard({ ID }) {
705711
/>
706712
</StyledIframeContainer>
707713
)}
708-
{metaEvidence.arbitrableInterfaceURI && isSafeNavigationUrl(metaEvidence.arbitrableInterfaceURI) && (
714+
{arbitrableInterfaceURL && (
709715
<ArbitrableInterfaceDiv>
710-
<a href={metaEvidence.arbitrableInterfaceURI} target="_blank" rel="noopener noreferrer">
716+
<a href={arbitrableInterfaceURL} target="_blank" rel="noopener noreferrer">
711717
<Icon type="double-right" style={{ marginRight: "5px" }} />
712718
Go to the Arbitrable Application
713719
</a>

src/components/claim-modal.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ReactComponent as RightArrow } from "../assets/images/right-arrow.svg";
1010
import useChainId from "../hooks/use-chain-id";
1111
import ETHAmount from "./eth-amount";
1212
import { klerosboardSubgraph } from "../bootstrap/subgraph";
13+
import { IPFS_GATEWAY } from "../utils/ipfs";
1314

1415
const StyledModal = styled(Modal)`
1516
max-width: calc(100vw - 32px);
@@ -83,8 +84,6 @@ const StyledHr = styled.hr`
8384
margin-bottom: 32px;
8485
`;
8586

86-
const ipfsEndpoint = "https://cdn.kleros.link";
87-
8887
const chainIdToParams = {
8988
1: {
9089
contractAddress: "0xdbc3088Dfebc3cc6A84B0271DaDe2696DB00Af38",
@@ -267,7 +266,7 @@ const ClaimModal = ({ visible, onOk, onCancel, displayButton, apyCallback }) =>
267266
const snapshots = airdropParams?.snapshots ?? [];
268267

269268
for (var month = 0; month < snapshots.length; month++) {
270-
responses[month] = fetch(`${ipfsEndpoint}/ipfs/${snapshots[month]}`);
269+
responses[month] = fetch(`${IPFS_GATEWAY}/ipfs/${snapshots[month]}`);
271270
}
272271

273272
const results = Promise.all(

src/helpers/rewards.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import PNKAbi from "../assets/contracts/pinakion.json";
33
import Web3 from "web3";
44
import { klerosboardSubgraph } from "../bootstrap/subgraph";
55
import { getReadOnlyRpcUrl } from "../bootstrap/web3";
6+
import { IPFS_GATEWAY } from "../utils/ipfs";
67

78
const CLAIM_MODAL_URL = "https://raw.githubusercontent.com/kleros/court/master/src/components/claim-modal.js";
89

@@ -52,7 +53,7 @@ async function getLatestSnapshotUrls() {
5253
);
5354
let matches = Array.from(claimModalCode.matchAll(reg));
5455
let urls = matches.map((r) => ({
55-
url: `https://cdn.kleros.link/ipfs/${r.groups.cid}/${r.groups.filename}.json`,
56+
url: `${IPFS_GATEWAY}/ipfs/${r.groups.cid}/${r.groups.filename}.json`,
5657
isGnosis: r.groups.filename.startsWith("xdai-"),
5758
}));
5859
if (urls.length === 0) {
@@ -64,7 +65,7 @@ async function getLatestSnapshotUrls() {
6465
);
6566
matches = Array.from(claimModalCode.matchAll(reg));
6667
urls = matches.map((r) => ({
67-
url: `https://cdn.kleros.link/ipfs/${r.groups.cid}/${r.groups.filename}.json`,
68+
url: `${IPFS_GATEWAY}/ipfs/${r.groups.cid}/${r.groups.filename}.json`,
6869
isGnosis: r.groups.filename.startsWith("xdai-"),
6970
}));
7071
}

src/utils/ipfs.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const IPFS_GATEWAY = "https://cdn.kleros.link";
2+
3+
//CID shape check for CIDv0 and CIDv1 in base32 (the standard's default encoding).
4+
const CID_REGEX = /^(Qm[1-9A-HJ-NP-Za-km-z]{44}|b[a-z2-7]{50,})([/?#]|$)/;
5+
6+
//Strips the common IPFS URI prefixes, leaving only the CID or CID/path.
7+
const toIpfsPath = (uri) =>
8+
uri
9+
.trim()
10+
.replace(/^(?:ipfs:|fs:)\/*/, "")
11+
.replace(/^\/?(?:ipfs\/)?/, "");
12+
13+
//Resolves a URI to an HTTP URL.
14+
//IPFS URIs in any of their common forms resolve to the Kleros gateway. Absolute http(s) URLs are returned unchanged.
15+
export const toHttpUrl = (uri) => {
16+
if (typeof uri !== "string" || uri.trim() === "") return undefined;
17+
if (/^https?:\/\//.test(uri.trim())) return uri.trim();
18+
return `${IPFS_GATEWAY}/ipfs/${toIpfsPath(uri)}`;
19+
};
20+
21+
//Evidence type URIs must be content-addressed.
22+
export const isContentAddressed = (uri) => typeof uri === "string" && CID_REGEX.test(toIpfsPath(uri));

src/utils/ipfs.test.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { IPFS_GATEWAY, isContentAddressed, toHttpUrl } from "./ipfs";
2+
3+
//Mainnet General Court policy
4+
const CID_V0 = "Qmd1TMEbtic3TSonu5dfqa5k3aSrjxRGY8oJH3ruGgazRB";
5+
6+
//IPFS docs example
7+
const CID_V1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi";
8+
9+
const GATEWAY_URL = `${IPFS_GATEWAY}/ipfs/${CID_V0}`;
10+
11+
describe("toHttpUrl", () => {
12+
it("resolves all common IPFS URI forms to the gateway", () => {
13+
expect(toHttpUrl(`/ipfs/${CID_V0}`)).toBe(GATEWAY_URL);
14+
expect(toHttpUrl(`ipfs/${CID_V0}`)).toBe(GATEWAY_URL);
15+
expect(toHttpUrl(`ipfs://${CID_V0}`)).toBe(GATEWAY_URL);
16+
expect(toHttpUrl(`ipfs:${CID_V0}`)).toBe(GATEWAY_URL);
17+
expect(toHttpUrl(`ipfs://ipfs/${CID_V0}`)).toBe(GATEWAY_URL);
18+
});
19+
20+
it("resolves legacy fs: forms to the gateway", () => {
21+
expect(toHttpUrl(`fs:/ipfs/${CID_V0}`)).toBe(GATEWAY_URL);
22+
expect(toHttpUrl(`fs://ipfs/${CID_V0}`)).toBe(GATEWAY_URL);
23+
});
24+
25+
it("resolves bare hashes to the gateway", () => {
26+
expect(toHttpUrl(CID_V0)).toBe(GATEWAY_URL);
27+
expect(toHttpUrl(`${CID_V0}/file.json`)).toBe(`${GATEWAY_URL}/file.json`);
28+
});
29+
30+
it("returns absolute http(s) URLs unchanged", () => {
31+
const curateURL = "https://curate.kleros.io/tcr/1/0x6e31D83B0c696f7D57241d3DffD0f2B628D14C67";
32+
expect(toHttpUrl(curateURL)).toBe(curateURL);
33+
expect(toHttpUrl("http://google.com")).toBe("http://google.com");
34+
});
35+
36+
it("returns undefined for empty or non-string input", () => {
37+
expect(toHttpUrl("")).toBeUndefined();
38+
expect(toHttpUrl(" ")).toBeUndefined();
39+
expect(toHttpUrl(null)).toBeUndefined();
40+
expect(toHttpUrl(undefined)).toBeUndefined();
41+
expect(toHttpUrl(42)).toBeUndefined();
42+
});
43+
44+
it("ignores surrounding whitespace", () => {
45+
expect(toHttpUrl(` /ipfs/${CID_V0} `)).toBe(GATEWAY_URL);
46+
expect(toHttpUrl(" https://curate.kleros.io ")).toBe("https://curate.kleros.io");
47+
});
48+
});
49+
50+
describe("isContentAddressed", () => {
51+
it("accepts all common IPFS URI forms", () => {
52+
expect(isContentAddressed(`/ipfs/${CID_V0}`)).toBe(true);
53+
expect(isContentAddressed(`ipfs/${CID_V0}`)).toBe(true);
54+
expect(isContentAddressed(`ipfs://${CID_V1}`)).toBe(true);
55+
expect(isContentAddressed(`ipfs:${CID_V0}`)).toBe(true);
56+
expect(isContentAddressed(`fs:/ipfs/${CID_V0}`)).toBe(true);
57+
});
58+
59+
it("accepts bare CIDv0 and base32 CIDv1 hashes, with or without a path", () => {
60+
expect(isContentAddressed(CID_V0)).toBe(true);
61+
expect(isContentAddressed(CID_V1)).toBe(true);
62+
expect(isContentAddressed(`${CID_V0}/file.json`)).toBe(true);
63+
});
64+
65+
it("rejects host-anchored and mutable URIs", () => {
66+
expect(isContentAddressed(`https://cdn.kleros.link/ipfs/${CID_V1}`)).toBe(false);
67+
expect(isContentAddressed("http://example.com")).toBe(false);
68+
expect(isContentAddressed("ipns://k51qzi5uqu5dgutdk6i1ynyzg")).toBe(false);
69+
expect(isContentAddressed("/ipns/k51qzi5uqu5dgutdk6i1ynyzg")).toBe(false);
70+
});
71+
72+
it("rejects strings that only resemble hashes", () => {
73+
expect(isContentAddressed("Qmtooshort")).toBe(false);
74+
expect(isContentAddressed(`${CID_V0}X`)).toBe(false);
75+
expect(isContentAddressed("whatever")).toBe(false);
76+
expect(isContentAddressed("some random text")).toBe(false);
77+
});
78+
79+
it("rejects accepted prefixes that are not followed by a CID", () => {
80+
expect(isContentAddressed("/ipfs/whatever")).toBe(false);
81+
expect(isContentAddressed("ipfs:whatever")).toBe(false);
82+
expect(isContentAddressed("ipfs::whatever")).toBe(false);
83+
expect(isContentAddressed("fs:/ipfs/whatever")).toBe(false);
84+
});
85+
86+
it("rejects empty or non-string input", () => {
87+
expect(isContentAddressed("")).toBe(false);
88+
expect(isContentAddressed(null)).toBe(false);
89+
expect(isContentAddressed(undefined)).toBe(false);
90+
});
91+
});

yarn.lock

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9056,12 +9056,7 @@ got@^7.1.0:
90569056
url-parse-lax "^1.0.0"
90579057
url-to-options "^1.0.1"
90589058

9059-
graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.6, graceful-fs@^4.2.0:
9060-
version "4.2.6"
9061-
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz"
9062-
integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==
9063-
9064-
graceful-fs@^4.1.2:
9059+
graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11:
90659060
version "4.2.11"
90669061
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
90679062
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==

0 commit comments

Comments
 (0)