From 09d16aa199b24bfa9ab7cb23e955d155d2b298b4 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 20 Mar 2026 09:17:11 +0000 Subject: [PATCH] [#376] Fall back to provided content on IPFS hash mismatch Previously, fallback content was only used when IPFS fetch failed. If IPFS returned content whose hash didn't match the on-chain hash (e.g. dummy CIDs pointing to unrelated IPFS content), the indexer returned 400 with no way to override. Now both storyline and plot routes: 1. Try IPFS content first, verify hash 2. If hash mismatches, try fallback content and verify its hash 3. Only fail if neither source provides hash-matching content Fixes #376 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/index/plot/route.ts | 25 ++++++++++++++++--------- src/app/api/index/storyline/route.ts | 28 ++++++++++++++++------------ 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index 5c6a479c..650ce342 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -74,24 +74,31 @@ export async function POST(req: Request) { decoded.args; // 4. Fetch content from IPFS (with fallback) - let content: string; + let content: string | null = null; try { const ipfsRes = await fetch(`${IPFS_GATEWAY}${contentCID}`, { signal: AbortSignal.timeout(IPFS_TIMEOUT_MS), }); if (!ipfsRes.ok) throw new Error(`IPFS status ${ipfsRes.status}`); - content = await ipfsRes.text(); + const ipfsContent = await ipfsRes.text(); + // Verify IPFS content hash matches on-chain hash + if (hashContent(ipfsContent) === contentHash) { + content = ipfsContent; + } + // If hash mismatches, fall through to fallback content below } catch { - if (!fallbackContent) { - return error("IPFS fetch failed and no fallback content provided", 502); + // IPFS fetch failed — fall through to fallback content below + } + + // 5. Try fallback content if IPFS content was unavailable or hash-mismatched + if (!content && fallbackContent) { + if (hashContent(fallbackContent) === contentHash) { + content = fallbackContent; } - content = fallbackContent; } - // 5. Verify content hash - const computedHash = hashContent(content); - if (computedHash !== contentHash) { - return error("Content hash mismatch"); + if (!content) { + return error("Content hash mismatch (IPFS and fallback both failed)"); } // 6. Get block timestamp diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index 97b90eb6..fd125e18 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -100,27 +100,31 @@ export async function POST(req: Request) { const writerType = await detectWriterType(writer); // 6. Fetch genesis plot content from IPFS (with fallback) - let genesisContent: string; + let genesisContent: string | null = null; try { const ipfsRes = await fetch(`${IPFS_GATEWAY}${openingCID}`, { signal: AbortSignal.timeout(IPFS_TIMEOUT_MS), }); if (!ipfsRes.ok) throw new Error(`IPFS status ${ipfsRes.status}`); - genesisContent = await ipfsRes.text(); + const ipfsContent = await ipfsRes.text(); + // Verify IPFS content hash matches on-chain hash + if (hashContent(ipfsContent) === openingHash) { + genesisContent = ipfsContent; + } + // If hash mismatches, fall through to fallback content below } catch { - if (!fallbackContent) { - return error( - "IPFS fetch failed and no fallback content provided", - 502 - ); + // IPFS fetch failed — fall through to fallback content below + } + + // 7. Try fallback content if IPFS content was unavailable or hash-mismatched + if (!genesisContent && fallbackContent) { + if (hashContent(fallbackContent) === openingHash) { + genesisContent = fallbackContent; } - genesisContent = fallbackContent; } - // 7. Verify genesis content hash - const computedHash = hashContent(genesisContent); - if (computedHash !== openingHash) { - return error("Genesis content hash mismatch"); + if (!genesisContent) { + return error("Genesis content hash mismatch (IPFS and fallback both failed)"); } // 8. Upsert storyline to Supabase