ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于区块链与IPFS的去中心化文件存储平台:从合约到前端全解析

基于区块链与IPFS的去中心化文件存储平台:从合约到前端全解析

简介:这是一套面向区块链与去中心化存储方向开发者的实战项目源码,基于以太坊智能合约、Solidity、Web3.js、React.js 与 IPFS 构建,实现类似 Dropbox 的去中心化文件存储平台。项目通过智能合约管理文件上传、下载、权限控制与交易记录,文件本体由 IPFS 分块哈希存储,兼顾不可篡改性与分发效率,适合想深入理解区块链与分布式系统结合场景的中级开发者学习。资源包共 26 个文件,约 1.23MB,包含 10 个 JavaScript 前端与脚本文件、4 个 JSON 配置、2 个 Solidity 合约、2 个 PNG 图标、1 个 HTML 入口、1 个 CSS 样式及 README、Truffle 迁移脚本、测试文件等,覆盖合约编写、前端交互与部署测试完整链路。目前已有 334 人学习下载。通过阅读与运行,可掌握合约与前端通信、IPFS 哈希上链、文件权限与付费逻辑设计等关键实现,并借助测试脚本与目录结构快速复现整个去中心化存储流程。

1. 当 Dropbox 被拆掉中心服务器:一个用区块链和 IPFS 搭的去中心化文件存储平台

想象一下这个场景:你团队想把设计稿、合同、代码包统一存到一个地方,但不想把文件交给任何一家公司的服务器。传统网盘的做法是文件传到中心机房,权限由平台数据库说了算;而这个项目把文件切片存进 IPFS,把「谁拥有哪个文件、谁有权访问」写进以太坊智能合约,前端用 React 接 Web3.js 跟链上合约对话。它本质上是一个 Dropbox 克隆,但去掉了中心化的存储节点和权限数据库。适合谁?适合想学 Solidity 合约开发、IPFS 内容寻址、Web3 前端集成的开发者,也适合需要理解「去中心化存储到底怎么落地」的全栈同学。下面我从目录结构一路拆到合约和前端调用,把跑通和改造的路径讲清楚。

2. 技术栈与目录结构:先看清这个项目由哪些零件拼起来

2.1 四层架构:前端、合约、存储、链上交互

这个项目的架构可以拆成四层,每一层职责清晰,改起来不会互相牵连。

第一层是 React 前端。它负责文件选择、上传进度展示、文件列表渲染和下载触发。用户看到的每一个按钮背后,都是一次 Web3.js 调用或 IPFS 写入。

第二层是 Solidity 智能合约。合约里维护两个核心映射:一个是文件哈希到所有者的映射,另一个是文件哈希到授权地址列表的映射。上传文件时调用合约的 addFile 方法,授权分享时调用 allow 方法,查询时调用 getFiles 方法。合约部署在以太坊上,一旦部署,权限逻辑就不可篡改。

第三层是 IPFS 网络。文件本身不存进区块链,因为链上存储成本极高。文件通过 IPFS 的 add 接口写入,返回一个内容哈希(CID),这个哈希再写进合约。下载时用 CID 从 IPFS 网关取回文件内容。

第四层是 Web3.js 桥接层。它连接前端和合约,负责账户获取、合约实例化、方法调用和事件监听。没有它,React 不知道合约部署在哪、用哪个账户签名。

这四层的关系是:React 调用 Web3.js,Web3.js 调用合约和 IPFS,合约存权限,IPFS 存内容。常见做法是把 IPFS 节点地址和合约地址放在一个 config 文件里,方便切换本地和测试网环境。

2.2 目录结构:每个文件夹对应一个明确职责

一个典型的项目目录长这样,我按实际拆包经验标注了每个目录的作用:

decentralized-storage/ ├── contracts/ │ └── FileStorage.sol # 核心合约,管理文件所有权和访问权限 ├── migrations/ │ └── 2_deploy_contracts.js # 部署脚本,把合约推上链 ├── src/ │ ├── components/ │ │ ├── App.js # 根组件,初始化 Web3 和合约实例 │ │ ├── FileUpload.js # 上传组件,调 IPFS 和合约 │ │ └── FileList.js # 文件列表,展示和下载 │ ├── utils/ │ │ └── ipfs.js # IPFS 客户端封装 │ └── index.js # 入口,挂载 React ├── truffle-config.js # Truffle 配置,指定网络和编译器版本 └── package.json # 依赖清单

contracts 目录是链上逻辑的唯一来源,改权限规则只动这里。migrations 目录决定合约部署到哪条链、用哪个账户。src/components 是前端交互层,FileUpload 和 FileList 是两个核心业务组件。src/utils/ipfs.js 把 IPFS 的 add 和 cat 操作封装成 Promise,避免组件里散落回调。truffle-config.js 里要配好 networks 字段,本地开发用 development 网络指向 8545 端口。

2.3 依赖清单:装什么、为什么装

package.json 里的依赖分三类。第一类是 React 生态:react、react-dom、react-scripts。第二类是 Web3 生态:web3、@truffle/contract。第三类是 IPFS 客户端:ipfs-http-client。常见做法是再加一个 dotenv,把合约地址和 IPFS 节点地址放进环境变量,避免硬编码。

安装命令很直接:

npm install web3 @truffle/contract ipfs-http-client react react-dom react-scripts dotenv

这里有个血泪经验:ipfs-http-client 的版本要和本地 IPFS 节点版本匹配,否则 add 方法会报协议不兼容。我一般先跑ipfs version确认节点版本,再选对应的客户端大版本。Truffle 用truffle init初始化合约项目,它会自动生成 migrations 目录和 truffle-config.js 骨架。

3. 核心合约拆解:FileStorage.sol 里到底写了什么

3.1 数据结构:两个 mapping 撑起权限体系

合约的核心数据结构不复杂,但设计得很实用。先看代码:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract FileStorage { // 文件哈希 => 所有者地址 mapping(string => address) public fileOwners; // 文件哈希 => 被授权地址列表 mapping(string => address[]) public fileAccess; // 某个用户拥有的所有文件哈希 mapping(address => string[]) public userFiles; event FileAdded(string fileHash, address owner); event AccessGranted(string fileHash, address user); // 上传文件后登记所有权 function addFile(string memory _fileHash) public { require(fileOwners[_fileHash] == address(0), "File already exists"); fileOwners[_fileHash] = msg.sender; userFiles[msg.sender].push(_fileHash); emit FileAdded(_fileHash, msg.sender); } // 授权其他地址访问 function allow(string memory _fileHash, address _user) public { require(fileOwners[_fileHash] == msg.sender, "Not the owner"); fileAccess[_fileHash].push(_user); emit AccessGranted(_fileHash, _user); } // 查询自己拥有的文件 function getFiles() public view returns (string[] memory) { return userFiles[msg.sender]; } // 检查是否有权访问 function hasAccess(string memory _fileHash, address _user) public view returns (bool) { if (fileOwners[_fileHash] == _user) return true; address[] memory allowed = fileAccess[_fileHash]; for (uint i = 0; i < allowed.length; i++) { if (allowed[i] == _user) return true; } return false; } }

逐段说明。fileOwners 映射记录每个文件哈希的原始上传者,这是权限的根。fileAccess 映射记录被授权者列表,用数组是因为一个文件可能分享给多个人。userFiles 映射让每个用户可以快速拉取自己上传过的所有文件,不用遍历整个链上数据。

addFile 方法里有一个 require 检查,防止同一个文件哈希被重复登记。这个检查很重要,因为 IPFS 的内容寻址意味着相同内容产生相同哈希,如果不拦,第二个人上传相同文件会覆盖所有权。allow 方法只允许所有者调用,用 msg.sender 做校验。hasAccess 是一个 view 方法,不消耗 gas,前端在下载前先调它判断权限。

参数说明:_fileHash 是 IPFS 返回的 CID 字符串,_user 是被授权者的以太坊地址。事件 FileAdded 和 AccessGranted 用于前端监听,上传成功后可以实时刷新列表。

3.2 部署脚本:migrations 里怎么把合约推上链

部署脚本决定合约地址,前端要靠这个地址实例化合约。看代码:

const FileStorage = artifacts.require("FileStorage"); module.exports = function (deployer) { deployer.deploy(FileStorage); };

这段脚本用 Truffle 的 deployer 把 FileStorage 合约部署到 truffle-config.js 里指定的网络。部署完成后,Truffle 会把合约地址写入 build/contracts/FileStorage.json,前端通过 @truffle/contract 读取这个 JSON 拿到 ABI 和地址。

常见做法是在 truffle-config.js 里配两个网络:development 指向本地 Ganache 或 Hardhat 节点,sepolia 指向测试网。本地开发用 development,速度快、不花真钱。部署命令是:

truffle migrate --network development

如果报「insufficient funds」,说明部署账户没有余额,本地节点重启后账户余额会重置,重新 migrate 即可。如果报「nonce too high」,清掉 build 目录重新部署。

3.3 合约与 IPFS 的分工:为什么文件不存链上

这里要讲清楚一个设计决策。区块链适合存状态和权限,不适合存大文件。把 1MB 文件写进合约,gas 费可能高到离谱,而且链上数据公开,隐私文件不能直接存。IPFS 适合存内容,它用内容哈希寻址,文件被切片分散到多个节点,天然去中心化。

所以这个项目的分工是:IPFS 存文件内容,合约存「谁拥有这个 CID」和「谁被允许访问这个 CID」。下载时先用合约校验权限,再用 CID 从 IPFS 取文件。这个分工是去中心化存储的通用模式,理解这一点,后面改任何功能都不会跑偏。

4. 前端集成:React 怎么跟合约和 IPFS 对话

4.1 初始化 Web3 和合约实例

前端启动时要做的第一件事是连接钱包、拿到账户、实例化合约。看 App.js 的核心逻辑:

import Web3 from "web3"; import FileStorageContract from "../build/contracts/FileStorage.json"; async function initWeb3() { // 优先使用浏览器注入的 provider(如 MetaMask) if (window.ethereum) { const web3 = new Web3(window.ethereum); await window.ethereum.request({ method: "eth_requestAccounts" }); const accounts = await web3.eth.getAccounts(); const networkId = await web3.eth.net.getId(); const deployedNetwork = FileStorageContract.networks[networkId]; const contract = new web3.eth.Contract( FileStorageContract.abi, deployedNetwork.address ); return { web3, account: accounts[0], contract }; } else { throw new Error("No Ethereum provider found"); } }

这段代码先检测 window.ethereum 是否存在,存在则请求账户授权。networkId 用来匹配合约部署的网络,如果前端连的是主网而合约部署在测试网,deployedNetwork 会是 undefined,这里要加错误处理。contract 实例用 ABI 和地址构造,之后所有合约调用都走这个实例。

参数说明:FileStorageContract.abi 是合约接口描述,deployedNetwork.address 是部署地址。常见坑是 networkId 类型不匹配,web3.eth.net.getId() 返回字符串,而 JSON 里的 networks 键是数字,比较时要统一类型。

4.2 上传流程:从文件选择到链上登记

上传是核心链路,分三步:文件写入 IPFS、拿到 CID、调合约 addFile。看 FileUpload.js 的实现:

import { create } from "ipfs-http-client"; const ipfs = create({ host: "localhost", port: "5001", protocol: "http" }); async function uploadFile(file, contract, account) { // 第一步:把文件写入 IPFS const added = await ipfs.add(file); const fileHash = added.path; // 第二步:调合约登记所有权 await contract.methods.addFile(fileHash).send({ from: account }); // 第三步:返回哈希供前端展示 return fileHash; }

ipfs.add 接收 File 对象,返回一个包含 path 和 cid 的对象。path 就是内容哈希,直接用作合约里的 _fileHash。contract.methods.addFile 返回一个事务对象,.send 触发签名和广播。这里要注意 gas 估算,如果合约方法复杂,默认 gas 可能不够,常见做法是显式传 gas: 300000。

上传大文件时,ipfs.add 可能耗时较长,前端要加 loading 状态。如果 IPFS 节点没启动,会报 ECONNREFUSED,检查ipfs daemon是否在跑。

4.3 下载流程:先校验权限再取文件

下载比上传多一步权限校验。看 FileList.js 的逻辑:

async function downloadFile(fileHash, contract, account) { // 先查链上权限 const allowed = await contract.methods .hasAccess(fileHash, account) .call({ from: account }); if (!allowed) { throw new Error("No access to this file"); } // 从 IPFS 取回内容 const chunks = []; for await (const chunk of ipfs.cat(fileHash)) { chunks.push(chunk); } const fileContent = new Blob(chunks); return fileContent; }

hasAccess 是 view 方法,用 .call 不消耗 gas。ipfs.cat 返回一个异步迭代器,用 for await 收集所有 chunk。最后用 Blob 包装成可下载对象。这里有个细节:ipfs.cat 在文件较大时可能超时,常见做法是加一个超时参数或改用 ipfs.get。

参数说明:fileHash 是链上登记的 CID,account 是当前用户地址。如果 hasAccess 返回 false,前端要给出明确提示,而不是静默失败。

4.4 授权分享:调 allow 方法把访问权交给别人

分享功能只涉及一次合约调用:

async function shareFile(fileHash, targetAddress, contract, account) { await contract.methods .allow(fileHash, targetAddress) .send({ from: account, gas: 100000 }); return true; }

targetAddress 是对方的以太坊地址。allow 方法内部会校验 msg.sender 是否是文件所有者,不是则 revert。前端要捕获 revert 错误并提示「只有文件所有者可以授权」。gas 设 100000 足够,因为只是往数组里 push 一个地址。

5. 避坑与排查:跑这个项目最容易翻车的几个地方

5.1 合约部署后前端读不到地址

现象:前端报「Cannot read property 'address' of undefined」。原因:networkId 不匹配,或者 build/contracts/FileStorage.json 没有更新。解决:确认 truffle-config.js 里的网络配置和钱包网络一致,重新跑truffle migrate --reset,检查 JSON 文件里的 networks 字段是否有对应 networkId。

5.2 IPFS 节点连不上

现象:上传时卡住或报 ECONNREFUSED。原因:本地 IPFS 节点没启动,或者端口不是 5001。解决:跑ipfs daemon启动节点,确认 API 端口。如果用的是远程节点,检查 CORS 配置,IPFS 默认只允许本地访问。

5.3 交易一直 pending

现象:调 addFile 后交易卡在 pending。原因:gas 价格太低或 nonce 冲突。解决:在 .send 里显式传 gasPrice,或者重置钱包账户。本地开发时重启 Ganache 会清空交易池,重新 migrate 即可。

5.4 文件哈希重复导致上传失败

现象:第二个人上传相同文件时 revert。原因:addFile 里的 require 检查拦截了重复哈希。解决:这是设计行为,不是 bug。如果业务需要允许重复上传,把 require 改成更新所有者,或者在前端加提示「该文件已存在」。

5.5 下载时权限校验通过但取不到文件

现象:hasAccess 返回 true,但 ipfs.cat 报错。原因:IPFS 节点没有该文件的副本,或者文件已被 unpin。解决:确认上传时的节点和下载时的节点是同一个,或者用公共网关做 fallback。常见做法是在 ipfs.js 里配多个网关地址,依次尝试。

6. 二次开发:从克隆到改造的几条实用路径

6.1 加一个文件版本管理

当前合约只记录文件哈希和所有者,没有版本概念。改造思路是在合约里加一个 mapping,把「逻辑文件 ID」映射到「版本哈希数组」。上传新版本时调 addVersion 方法,把新 CID push 进数组。前端展示时默认取最新版本,同时提供历史版本下拉。这个改造只动合约和 FileList 组件,IPFS 层不用变。

6.2 把权限从「地址列表」升级为「角色控制」

现在的 allow 方法是往数组里 push 地址,查询时遍历。文件多了以后遍历成本高。改造思路是引入 OpenZeppelin 的 AccessControl,定义 OWNER 和 VIEWER 两个角色,用 grantRole 和 hasRole 替代数组。这样权限查询是 O(1),而且支持批量授权和撤销。合约代码量会增加,但前端调用方式基本不变。

6.3 用事件索引做文件列表缓存

当前 getFiles 方法返回当前用户的所有文件哈希,数据量大时前端渲染会卡。改造思路是监听合约的 FileAdded 事件,把事件日志存进前端状态或 IndexedDB,列表从缓存读,只在必要时调合约校验。这样首屏加载快很多,代价是要处理事件重放和链重组。

6.4 验证改造是否生效的检查清单

改完合约后,先跑truffle test确认单元测试通过。再跑truffle migrate --reset重新部署,更新前端里的合约地址。然后手动走一遍上传、授权、下载三条链路。最后用另一个账户登录,确认权限隔离生效。我一般会在合约里加一个 version 常量,每次改造递增,前端启动时打印出来,避免连到旧合约。

6.5 一个我踩过的坑

有一次我改了合约的 addFile 方法签名,但忘了更新前端 ABI,结果前端调用的还是旧方法,交易一直 revert。排查了半天才发现 build/contracts 里的 JSON 没重新生成。从那以后我每次改合约都强制走一遍truffle compile && truffle migrate --reset,然后确认前端引用的 JSON 文件时间戳是最新的。这个习惯帮我省了很多玄学调试时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进