简介:这是一套基于以太坊、IPFS、Solidity与JavaScript构建的去中心化文件存储平台完整源码,适合希望掌握区块链应用开发、熟悉DApp架构的开发者作为学习与实战参照。平台通过以太坊智能合约管理文件上传、下载、权限控制与交易记录,利用IPFS完成内容寻址及分布式存储,前端基于React.js与Web3.js实现与链上交互,可替代传统集中式云存储场景。压缩包共26个文件,以JavaScript脚本、Solidity合约、JSON配置、HTML页面及说明文档为主,同时包含环境变量示例、Truffle部署迁移脚本、前端组件和测试文件,整体大小仅1.23MB,目录划分清晰便于按模块理解。目前已有333人学习下载,读者可借此直观了解智能合约部署流程、Web3.js调用方式和IPFS文件上链逻辑,还可分析合约中权限控制与付费机制的设计思路,并在此基础上扩展功能或重构界面。
1. 为什么把文件从中心化存储挪到IPFS和以太坊上:这个去中心化文件存储平台能解决什么
如果你被问到“拿区块链做文件存储”,第一反应多半是“把文件写到链上”。但真正动手做这个项目之后你会发现,直接把文件丢进以太坊不但贵得离谱,而且技术上根本不可行。这个仓库用的是另一种思路:用IPFS存文件本体,用以太坊智能合约存文件的索引和所有权——界面长得像Dropbox,底层没有一台你信任的中央服务器。它适合两类人:一类是想做出一个“数据在自己手里”的私有网盘的应用开发者,另一类是想把Solidity、Web3.js、React.js这条链路的全栈DApp流程跑通的学习者。下面我按自己复现这个项目的顺序,把架构、合约、前端和踩坑一条条拆开讲。
2. 技术架构拆解:为什么文件本体进IPFS,元数据进以太坊合约
2.1 为什么选IPFS而不是传统对象存储:内容寻址与CID原理
IPFS的核心是“内容寻址”。传统对象存储(比如阿里云OSS、AWS S3)用的是位置寻址:你通过一个URL去访问文件,URL和内容本身没有强绑定关系。IPFS不是这样,它把你上传的文件内容做一次SHA-256哈希,生成一个内容标识符(CID)。同一份文件不管上传多少次,CID都一样,这就天然带来了两个好处:文件去重(相同内容不会重复存两份)和防篡改(CID变化就说明文件内容被动过手脚)。
这个项目里,IPFS承担的是文件本体存储。你上传一张照片,IPFS节点会把它切成若干256KB的块,每个块都有一个独立的哈希,块之间用MerkleDAG组织起来,顶层就是文件CID。你下载时只需要拿到CID,IPFS网络就会按图索骥把块拼回来。这个过程在代码里就是一个ipfs.add()调用,返回的path字段就是CID。
要注意的是,IPFS本身不保证文件永久在线。它没有像Filecoin那样的激励层,节点只缓存它感兴趣的内容。你本地节点上传了文件,如果你关机或者GC(垃圾回收)清掉未固定的块,文件就没了。所以这个项目里凡是上传操作,几乎都必须跟随一个pin动作,把文件固定到节点上。后面避坑章节我会详细讲这个点。
2.2 以太坊在架构里的定位:为什么只存元数据不存文件本体
以太坊在这套架构里不是一个存储层,而是一个“公证层”。它记录的是:谁在什么时间上传了什么文件(CID值),以及谁有权访问这个文件。这种分工是成本逼出来的——以太坊的Gas费是按存储和计算消耗算的,SSTORE指令往链上写一个32字节的slot就要消耗20000Gas,按当时的行情折算成人民币,写1MB数据上去可能要付几十块钱,更不用说还有区块Gas上限的限制。
所以这个项目的智能合约只保存四个字段:文件ID、IPFS的CID、文件名称、所有者地址。CID一般也就几十字节,这对链上存储来说是完全可以接受的开销。真正的大文件数据流走IPFS,以太坊上只记“凭证”。这个边界想清楚,整个项目就不容易跑偏——很多初学者会试图把文件内容塞进合约的string字段,这是最容易翻车的设计错误。
合约里还定义了FileUploaded事件。事件在Solidity里有双重作用:一是给前端提供一个“链上通知机制”,当有文件上传时,前端可以通过监听事件拿到交易信息实时刷新列表,而不需要每次都轮询;二是事件的日志数据存在eth_getLogs里,比直接改合约状态便宜得多。这个项目中事件的使用很克制,只在uploadFile()里触发一条,这点设计得不错。
2.3 项目目录结构与关键依赖盘点
复制完仓库后第一件事是看目录结构。典型的项目布局长这样:
decentralized-file-storage-platform/ ├── contracts/ │ └── FileStorage.sol # Solidity智能合约 ├── migrations/ │ └── 1_deploy_contracts.js # 合约部署脚本 ├── src/ │ ├── components/ # React组件 │ ├── service/ │ │ └── web3Service.js # Web3.js封装 │ └── App.js ├── truffle-config.js # Truffle或Hardhat配置 └── package.json工程方案我建议用Hardhat而不是Truffle。Hardhat内置了hardhat console、Stack Trace调试和自动化的TypeScript支持(如果你用的是TS),在处理合约报错时体验好很多。你要是照原仓库用Truffle也没问题,但Hardhat的node默认自带一个开发链,省掉装Ganache的步骤,新手更友好。
package.json里关键依赖是这三组:web3(和MetaMask通信)、ipfs-http-client(和IPFS节点通信)、@openzeppelin/contracts(如果你需要Ownable这类权限控制)。React本身不算难点,真正的复杂度在前端把Web3.js和IPFS两个客户端串起来,各自初始化、各自维护状态。整理好依赖版本再动手,能避开不少奇奇怪怪的类型错误。
3. Solidity智能合约实战:文件映射与所有权管理的部署细节
3.1 合约设计:结构体、映射和事件
看一下FileStorage.sol的核心实现。这类合约的结构非常固定:一个结构体描述文件元数据,一个映射存文件ID到结构体,一个计数器自增分配ID。
// contracts/FileStorage.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract FileStorage { struct File { string cid; // IPFS内容标识符 string fileName; // 原始文件名,仅作展示 address owner; // 上传者地址 uint256 timestamp; // 上传时间(unix时间戳) } mapping(uint256 => File) public files; // fileId => File mapping(address => uint256[]) private ownerFiles; // 所有者 => 文件ID数组 uint256 private fileCounter; event FileUploaded( uint256 indexed fileId, string cid, string fileName, address indexed owner, uint256 timestamp ); function uploadFile(string memory _cid, string memory _fileName) public { fileCounter++; File storage newFile = files[fileCounter]; newFile.cid = _cid; newFile.fileName = _fileName; newFile.owner = msg.sender; newFile.timestamp = block.timestamp; ownerFiles[msg.sender].push(fileCounter); emit FileUploaded(fileCounter, _cid, _fileName, msg.sender, block.timestamp); } function getFile(uint256 _fileId) public view returns ( string memory, string memory, address, uint256 ) { require(_fileId > 0 && _fileId <= fileCounter, "file does not exist"); File storage f = files[_fileId]; return (f.cid, f.fileName, f.owner, f.timestamp); } function getMyFiles() public view returns (uint256[] memory) { return ownerFiles[msg.sender]; } }这里有几个设计决策值得说清楚。ownerFiles[msg.sender]用了mapping(address => uint256[]),是为了让前端能用getMyFiles()拿到当前账户的文件ID列表,再逐个查getFile()。如果不做这个反向索引,前端就得遍历所有文件ID,在本地链上还好,在测试网或者主网上就是一场灾难——一个for循环几十上百次RPC调用,延迟高到没法用。
File storage newFile = files[fileCounter]这一句是Solidity的storage引用语法,意思是newFile直接指向了files[fileCounter]的存储位置,修改newFile就是修改合约状态。如果写memory就是复制一份临时副本,改完不会存到链上。这两种写法的差别初学者很容易踩坑,典型错误是函数执行完,状态没变化,Gas还照扣。
3.2 Hardhat部署脚本:配置、Gas和账户管理
部署用Hardhat的话,先建hardhat.config.js:
// hardhat.config.js require("@nomiclabs/hardhat-ethers"); module.exports = { solidity: "0.8.19", networks: { hardhat: { chainId: 1337 }, }, paths: { artifacts: "./src/artifacts", // 把ABI输出到前端能读到的位置 } };chainId建议显式设成1337,这是MetaMask对Hardhat内置链的默认识别ID。不设的话有时MetaMask会把这个网络当成未知网络,连接时前端报错。paths.artifacts写成./src/artifacts,这一步很多人会漏——合约编译后的ABI文件默认生成在./artifacts,前端在src目录里引用路径很容易写错,建议直接指向src/artifacts,前端require就不用管相对路径了。
部署脚本:
// scripts/deploy.js const hre = require("hardhat"); async function main() { const FileStorage = await hre.ethers.getContractFactory("FileStorage"); const fileStorage = await FileStorage.deploy(); await fileStorage.deployed(); console.log("FileStorage 合约地址:", fileStorage.address); // 把合约地址导出到前端配置,避免每次部署手改 const fs = require("fs"); fs.writeFileSync( "./src/contract-address.json", JSON.stringify({ address: fileStorage.address }, null, 2) ); } main().catch((error) => { console.error(error); process.exitCode = 1; });部署后紧接着要做的一件事是把合约地址写进前端配置。如果你不写成文件,而是每次部署后手动粘到React组件里,总有那么一次会忘记更新,前端连的合约是旧地址,所有调用全部落空。把地址——以及ABI——在部署脚本里同步导出到src目录,是这套流程里性价比最高的习惯。
3.3 合约权限模型:owner和访问控制的边界
这个仓库的合约没有做复杂的权限控制,getFile()是public的,任何地址只要知道fileId就能查到CID。也就是说上传到IPFS的文件在链上是公开可读的,合约只保证了“谁上传的”这个事实不可篡改。如果要做真正的私有文件,需要额外设计——常见做法是上传前用对称密钥加密文件内容,再把密钥用椭圆曲线加密传给授权地址,链上只存密文CID,没有密钥就拿不到明文。
开发时可先把权限模型放一放,但要对它有清晰认知。测试合约时我一般会验证这几个场景:普通地址调用uploadFile()能成功;重复调用时fileCounter递增;用非owner地址调用getMyFiles()返回空数组(因为ownerFiles里没有该地址)。这些断言在Mocha里写起来很快,但能帮你尽早确认合约的基础行为是正常的。
4. Web3.js加React.js前端链路:上传、展示、下载的完整实现
4.1 连接钱包:Web3.js初始化的正确姿势
前端的第一道关卡是Web3.js连接MetaMask。window.ethereum是MetaMask注入的EIP-1193 Provider对象,Web3.js基于它构造实例:
// src/service/web3Service.js import Web3 from "web3"; let web3; export function getWeb3() { if (web3) return web3; if (window.ethereum) { web3 = new Web3(window.ethereum); } else if (window.web3) { // 老式DApp浏览器兜底:使用旧版Global web3 web3 = new Web3(window.web3.currentProvider); } else { // 没有MetaMask时,连接公共节点,只能读不能写 web3 = new Web3("https://localhost:8545"); } return web3; } export async function requestAccounts() { const accounts = await window.ethereum.request({ method: "eth_requestAccounts" }); return accounts; }注意eth_requestAccounts是EIP-1102定义的异步方法,它返回的是一个Promise,Pending状态下MetaMask会弹出授权窗口。不要用eth_accounts——这个方法只返回已经授权的账户,如果用户之前没授权,拿到的是空数组,会误导你判断成没有账户。
初始化合约实例时要传入两个东西:ABI和合约地址。ABI从src/artifacts/FileStorage.json引入,合约地址从src/contract-address.json读:
const contract = new web3.eth.Contract( FileStorageArtifact.abi, contractAddress );这里有个小坑:ABI是JSON对象,require进来后要取.abi字段,Truffle导出的artifact文件顶部还会有contractName、networks这些额外字段,直接整个传进去Web3也能解析出来,但多了一层潜在问题——和别的地方用一个全量JSON做比较时可能不相等。规范写法是只传.abi。
4.2 文件上传流程:IPFS和合约的双写链路
上传是本项目最核心的路径,前后端各做一半。先用ipfs-http-client把文件传到IPFS节点,拿回CID,再调合约的uploadFile()把CID和文件名上链:
// src/service/storageService.js import { create } from "ipfs-http-client"; import { getWeb3 } from "./web3Service"; import FileStorageArtifact from "../artifacts/FileStorage.json"; import contractConfig from "../contract-address.json"; const ipfs = create({ host: "localhost", port: "5001", protocol: "http" }); export async function uploadToIpfs(file) { const added = await ipfs.add(file, { pin: true, // 主动固定文件块,防止被GC清理 timeout: 60000 // 100MB级文件上传给足一分钟 }); return added.cid.toString(); } export async function storeFileToChain(cid, fileName) { const web3 = getWeb3(); const accounts = await web3.eth.getAccounts(); const contract = new web3.eth.Contract( FileStorageArtifact.abi, contractConfig.address ); const gasEstimate = await contract.methods .uploadFile(cid, fileName) .estimateGas({ from: accounts[0] }); const receipt = await contract.methods.uploadFile(cid, fileName).send({ from: accounts[0], gas: Math.floor(gasEstimate * 1.3), // 留30%缓冲,防止Gas价格波动 }); return receipt; }ipfs.add(file)里的file可以是File对象、Blob或Buffer,这是ipfs-http-client对浏览器环境做得最好的地方——它内部做了流式处理,不会把整个文件一次性读进内存。pin: true参数决定了IPFS节点是否把内容固定到本地仓库。如果不主动pin,节点会把未引用的块视为临时缓存,在GC周期里回收掉,之后的访问就会404。
Gas估算这里,先estimateGas再send,是一种稳妥的防御:链上Gas价格是浮动的,直接给一个写死的gasLimit在拥堵时很可能会让交易Pending到超时。留30%的余量在后半段代码里是个习惯,大多数链上交易的实际消耗会比估算值低一些,但这个余量能兜住边缘情况。
4.3 文件列表与下载:从合约读CID,从IPFS取流
文件列表在React里做两件事:挂载时拉一次链上状态,再用事件订阅做增量更新。先看拉取和渲染部分:
// src/components/FileList.jsx import React, { useEffect, useState } from "react"; import { getWeb3 } from "../service/web3Service"; import FileStorageArtifact from "../artifacts/FileStorage.json"; import contractConfig from "../contract-address.json"; export default function FileList() { const [files, setFiles] = useState([]); useEffect(() => { async function fetchFiles() { const web3 = getWeb3(); const accounts = await web3.eth.getAccounts(); const contract = new web3.eth.Contract( FileStorageArtifact.abi, contractConfig.address ); const fileIds = await contract.methods.getMyFiles().call({ from: accounts[0] }); const fileList = []; for (const id of fileIds) { const meta = await contract.methods.getFile(id).call(); fileList.push({ id: id.toString(), cid: meta[0], fileName: meta[1] }); } setFiles(fileList); } fetchFiles(); }, []); return ( <ul> {files.map((file) => ( <li key={file.id}> <span>{file.fileName}</span> <span>{file.cid.slice(0, 12)}...</span> <button onClick={() => downloadFromIpfs(file.cid)}>下载</button> </li> ))} </ul> ); }事件订阅做法是用contract.events.FileUploaded({ fromBlock: "latest" })返回一个EventEmitter,在data回调里把新文件推入状态数组。这个思路值得写上,因为链上状态更新和本地状态可能脱节——用户从MetaMask发出交易到矿工确认,中间有几十秒延迟,如果只是发起交易后立刻刷新,大概率看不到新文件,监听事件才是可靠做法。
下载和上传方向相反:先从合约拿到CID,再从IPFS拉数据。IPFS取流和普通HTTP请求不太一样,ipfs.cat()返回的是一个AsyncIterable流,需要自己拼块:
export async function downloadFromIpfs(cid) { const chunks = []; for await (const chunk of ipfs.cat(cid)) { chunks.push(chunk); } const blob = new Blob(chunks); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "download"; a.click(); URL.revokeObjectURL(url); // 释放临时URL占用的内存 }URL.createObjectURL创建的临时URL会占用浏览器内存直到文档卸载,所以用完后要revokeObjectURL。这里的文件名如果是中文,建议在链上存的时候就用encodeURIComponent(fileName)编码,否则下载时个别浏览器会乱码。
5. 部署避坑指南:MetaMask、IPFS节点和链上状态的五类常见问题
5.1 MetaMask连不上本地开发链,交易一直Pending
现象:本地节点已经在跑,MetaMask里也把网络切到了localhost:7545,但发送交易后一直Pending,几分钟后在MetaMask里报错,交易没有上链。
原因:最常见是Chain ID不匹配。Ganache 2.x默认的Chain ID是1337,但在MetaMask“自定义网络”里如果只填了Network ID或RPC URL,漏掉Chain ID,MetaMask就会把它当一个未知网络,交易签名时使用错误Chain ID,节点校验签名失败后静默丢弃。
解决:在truffle-config.js或hardhat.config.js里显式写chainId: 1337,并且让节点和MetaMask两边的RPC URL、Chain ID完全一致。另一个隐蔽点:Ganache如果用了--port 7545,而MetaMask访问的是http://127.0.0.1:8545,连到的是另一个节点,账户余额对不上。
5.2 IPFS文件访问404:节点没有Pin文件
现象:上传成功后,用浏览器打开http://localhost:8080/ipfs/<CID>能正常下载;但几小时后或者重启IPFS节点再访问,返回404或空内容。神奇的是其他节点上传的文件都能访问,就这个文件丢了。
原因:IPFS节点只在文件被add进来的时候把它放在缓存区,这个区域受GC控制。GC默认以StorageMax为阈值,当仓库总量超过限制时,它会把未标记为“固定”(Pinned)的块清理掉。文件块被清理后,本地节点就不再有该内容,公共网关也就访问不到了。
解决:上传后立刻调用ipfs.pin.add(cid),或者像我上面写的在add时设置pin: true。这二选一就行,但要注意:pin: true只在当前IPFS节点生效,如果后续你换了节点,文件不会自动跟随,需要重新pin。生产级做法是接Pinata这类远端固定服务,在add时把文件发给Pinata的节点做固定,保证7x24小时在线。
5.3 大文件上传导致浏览器内存溢出
现象:上传80MB以上的视频文件时,标签页直接卡死,控制台报Out of memory或Allocation failed - process out of memory,其他页面也跟着崩溃。
原因:代码里用了await ipfs.add(fs.readFileSync(filePath))这种写法,把整个文件一次性读成Buffer再交给IPFS。浏览器环境没有Node的fs模块,但用file.arrayBuffer()也是一样的逻辑——都要把整个文件加载进内存。IPFS客户端虽然在内部做流式处理,但入口如果喂给它的是完整的Buffer,内存占用照样居高不下。
解决:在前端上传时不要用arrayBuffer(),直接把File对象传给ipfs.add(),它会内部走流式读取。同时配合ipfs-http-client的addAll()做分块:
import { create } from "ipfs-http-client"; const ipfs = create({ host: "localhost", port: "5001", protocol: "http" }); async function uploadLargeFile(file) { const generator = async function* () { yield { path: file.name, content: file }; }; const result = await ipfs.addAll(generator(), { pin: true }); for await (const res of result) { return res.cid.toString(); } }addAll接收的是AsyncIterable,它把文件切成分片逐个读取,内存占用几乎不随文件大小增长。这个方法对100MB以内的文件性能足够,更大文件建议走客户端直接到IPFS节点的流式通道,绕过浏览器内存瓶颈。
5.4 刷新页面后文件列表消失,事件监听失效
现象:上传文件后列表正常显示,但刷新浏览器,列表变成空白,再等一会儿也没有恢复。重新连接MetaMask甚至要等交易重新确认。
原因:常见的两类实现问题反复导致这个现象。第一种是列表数据只存在组件内部state里,刷新后没重新向合约查询——前端没把链上数据当作唯一的“真相源”。第二种是事件订阅写在了浏览器的global scope里,它确实在跑,但data回调里的setState挂载到了已经被卸载的组件实例上,React不承认这个更新,于是状态永远不刷新。
解决:在根组件的useEffect里挂载订阅,并把订阅和组件生命周期绑定,卸载时调用.removeAllListeners()清理;同时每次挂载都主动调一次getMyFiles()全量拉取。这是DApp前端的标准姿势:内存持久化的React状态 + 链上事件增量更新。
5.5 伪私有:合约是公开的,任何人拿CID都能下载
现象:界面设计了“我的文件”列表,用户以为只有自己能看自己的文件,但把某个文件的CID复制出来,用一个没有登录任何账户的浏览器打开IPFS网关地址,文件直接就能下载。
原因:IPFS没有访问控制的概念,文件一旦上传到公共节点,任何知道CID的人都可以取走。合约里的ownerFiles只是把你的文件ID索引到了你的地址名下,它记录的是“谁上传的”,不是“谁能读”。
解决:在业务层做加密。上传前用AES-GCM对称密钥加密文件内容,再把密钥通过msg.sender的地址做二次校验(比如把密钥存在链上并授权给特定地址),这样才能实现真正意义上的私有文件。这个仓库定位是公共资料分享场景,所以权限这块不是重点,但如果你的需求是私人网盘,必须补上这一步。
6. 端到端验证:用CID完整性校验给整个系统做一次体检
部署完成后别急着收工,先跑一遍完整的端到端流程,确认三个独立的子系统(IPFS、以太坊、前端)真的协同工作。我习惯用手上的测试文件走一个清单:上传文件,拿到CID后手动打开网关确认内容可访问;调合约读取文件元数据,核对CID和文件名和上传时一致;刷新页面,确认列表从链上重新拉取成功;再下载一次文件,用本地工具计算哈希比对。
这个项目的验证点在于IPFS和链上的一致性。IPFS的文件ID就是内容的哈希,所以我用一把“哈希再哈希”的校验就能验证整条链路没有损坏文件:
// 校验从IPFS下载的文件和上传时是否一致 async function verifyIntegrity(uploadedBuffer, downloadedBlob) { const uploadedHash = await sha256(uploadedBuffer); const downloadedHash = await sha256(await downloadedBlob.arrayBuffer()); return uploadedHash === downloadedHash; } async function sha256(buffer) { const crypto = window.crypto || require("crypto"); const digest = await crypto.subtle.digest("SHA-256", buffer); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); }这个校验在中心化存储里没有意义——服务器已经帮你保证了一致性;但在IPFS这种去中心化流动的网络里,文件可能在多个节点间多次中转,任何一个节点的损伤都会在MerkleDAG校验时暴露。上传时IPFS负责生成CID,下载时IPFS负责校验块哈希,链上CID是中间那个“锚”,哈希比对就是最直接的体检报告。
这个项目还有几个可以做的延伸方向。第一个是文件权限,把文件内容做AES加密、密钥通过合约按地址分发,这是从“公开网盘”到“私有网盘”质变的关口。第二个是持久化,本地IPFS节点离线文件就没了,接上Pinata或Filecoin的固定服务才能支撑真正意义上的线上服务。第三个是前端体验,现在的界面是基础的列表加按钮,可以加上文件类型图标、上传进度条、拖拽上传,这些在React里都是花不了多少时间但体验提升明显的事。
我自己做完这个项目后踩得最深的一个坑是:在本地一切正常,一换到测试网就全部失灵。后面才发现是IPFS节点连的是localhost:5001,而测试网环境根本不具备本地节点。从那以后我每次部署DApp,都强制把“存储服务是否和生产环境网络可达”放进检查清单第一位。这项目的源码结构干净、依赖清晰,很适合作为你第一个真正能跑的区块链全栈项目。希望帮到你。
本文还有配套的精品资源,点击获取