简介:该项目是基于以太坊与IPFS构建的去中心化文件存储平台,可作为Dropbox的区块链替代实现,面向具备JavaScript与前端基础、希望入门智能合约和Web3开发的工程师。Solidity合约负责文件上传、下载与权限控制,Web3.js连接前端与链上逻辑,React.js呈现交互界面,IPFS完成内容寻址存储,整体技术链路完整清晰。压缩包共26个文件,以JavaScript源码、JSON配置、Solidity合约及PNG图标为主,另有环境变量示例、README说明与Truffle部署脚本,整体约1.23MB;已有334人学习下载。目录按合约、前端、迁移测试及资源文件区分布置,配合说明文件可快速理解各模块职责。阅读源码与配置可掌握从合约编写、编译迁移到前端调用的全流程,理解哈希上链、去中心化文件寻址等核心机制,还能复用以太坊账户权限校验、文件哈希上链等关键逻辑,是区块链存储方向较直观的实战样例。
1. 去中心化文件存储不是把文件扔上链:这个存储平台到底做了什么
第一次看到“用以太坊和 IPFS 做 Dropbox 克隆”这个描述时,我一度以为是把文件内容直接写进合约,结果跑起来才发现完全不是那么回事。这个去中心化文件存储平台把 Dropbox 的能力拆成了两段:文件本体上传到 IPFS,得到内容寻址的 CID;以太坊合约只保存这个 CID、文件元数据和访问权限。这样设计之后,大文件不再占用链上空间,上传下载速度也不受区块出块限制,同时权限和归属记录不可篡改。适合正在学 DApp 全栈、想把智能合约和分布式存储真正串起来复现的新手,也适合想理清链上链下边界的前端开发者。
2. 架构拆解:为什么文件内容走 IPFS、文件索引走以太坊
如果只是照着源码跑一遍,很容易忽略这个项目最重要的设计决定:存储和索引完全分层。链上存储每一字节都要消耗 gas,任何“把文件内容放合约”的方案都在工程上不成立。把文件内容交给 IPFS,把文件证明交给以太坊,等于用两种不同成本的资源做各自擅长的事。
2.1 三层架构:React 前端、Solidity 合约、IPFS 节点
整个调用链可以分成三步:前端 React 组件读取用户选择的文件,通过ipfs-http-client上传到本地 IPFS 节点;上传成功后拿到一个内容寻址的 CID;随后用 Web3.js 调用 Solidity 合约,把 CID、文件大小、文件名和归属地址写入链上记录。前端任何时候都不把文件主体发送给合约,只发送 CID。
我复现时习惯把 IPFS 和合约客户端统一封装到一个storage.js里,下面是简化后的调用链:
// storage.js —— 浏览器端统一封装 import { create } from 'ipfs-http-client' import Web3 from 'web3' // 连接本地 IPFS 节点,5001 是 API 端口 const ipfs = create({ host: '127.0.0.1', port: 5001, protocol: 'http' }) // 连接 Ganache 测试链 const web3 = new Web3('http://127.0.0.1:7545') // contractAddress 和 contractAbi 来自 truffle 部署产物 const storageContract = new web3.eth.Contract(contractAbi, contractAddress) export async function uploadFile(file, account) { // 第一步:文件内容进 IPFS const buffer = await file.arrayBuffer() const result = await ipfs.add({ path: file.name, content: Buffer.from(buffer) }) const cid = result.cid.toString() // 第二步:CID 和元数据进合约 const receipt = await storageContract.methods .storeFile(cid, file.name, file.size, false) .send({ from: account, gas: 300000 }) return { cid, receipt } }这里四个参数要留意:host和port指向本地 IPFS API 端口 5001,和后面下载用的网关端口 8080 是两个概念;Buffer.from(buffer)是为了兼容浏览器环境,否则ipfs.add会拒绝文件内容;gas: 300000是我调试后确认够用的一次合约调用限额;storeFile的第四个参数false代表公开文件,如果要做私有文件,还需要额外的授权逻辑。
2.2 为什么不用以太坊直接存文件、也不用 IPFS 管权限
以太坊存储成本不是线性的,写入一个动态字符串、一个 mapping 键值都要消耗 gas。如果真把几百 KB 的文件内容塞进交易数据,gas 会直接超出区块上限,测试网也会卡到怀疑人生。IPFS 则相反,本地节点存储文件几乎没有边际成本,内容寻址天然去重,适合承载“胖内容”。
但 IPFS 有一个明显缺点:它不负责“谁能访问”。只要文件被 pin 在节点上,任何人拿到 CID 都能通过网关拉取。所以这个项目才把权限判断放在合约里。合约记录文件的 owner 和已授权地址,前端在组装箱下载链接之前先调用canAccess做校验。权限变更也走链上交易,留下一份可审计的记录。
下面这张表概括了项目里两种存储的职责边界:
| 职责 | 承担者 | 保存内容 | 可篡改性 | 主要成本 |
|---|---|---|---|---|
| 文件内容 | IPFS | 文件二进制或分片 | 内容寻址,改动内容会改变 CID | 节点磁盘空间 |
| 文件索引 | 以太坊 | CID、文件名、所有者 | 需私钥发起交易 | 每次写入消耗 gas |
| 访问控制 | 以太坊 | 授权 mapping 记录 | 仅 owner 可改 | 授权交易消耗 gas |
还要注意 IPFS 的 pin 机制。本地节点默认不会永久保留所有上传过的块,如果节点执行 GC,未 pin 的内容可能被清理。这个项目如果要长期保存文件,必须在ipfs.add后调用ipfs.pin.add(cid),或者在节点配置里开启自动 pin。很多复现跑到一半文件丢失,问题都出在这里。
2.3 合约里的核心数据结构:文件记录与权限映射
合约数据结构决定了整个平台的上限。我在复现时整理了一份最小但完整的 Solidity 设计,用自增 id 做文件索引,用嵌套 mapping 做权限表:
pragma solidity ^0.8.0; contract FileStorage { // 文件元数据记录,注意不存文件内容 struct FileRecord { string cid; // IPFS 内容寻址标识 string filename; // 原始文件名 uint256 size; // 文件大小,字节数 uint256 timestamp; // 上传时间 address owner; // 文件所有者 bool isPrivate; // 是否私有 } // 用自增 id 做索引,避免用 string 做 mapping key mapping(uint256 => FileRecord) public files; mapping(uint256 => mapping(address => bool)) private accessControl; uint256 private fileCounter; event FileStored(uint256 indexed fileId, string cid, string filename, address indexed owner); event AccessGranted(uint256 indexed fileId, address indexed user); function storeFile(string memory cid, string memory filename, uint256 size, bool isPrivate) public returns (uint256) { fileCounter++; files[fileCounter] = FileRecord({ cid: cid, filename: filename, size: size, timestamp: block.timestamp, owner: msg.sender, isPrivate: isPrivate }); if (isPrivate) { accessControl[fileCounter][msg.sender] = true; } emit FileStored(fileCounter, cid, filename, msg.sender); return fileCounter; } function grantAccess(uint256 fileId, address user) external { require(files[fileId].owner == msg.sender, "only owner can grant access"); accessControl[fileId][user] = true; emit AccessGranted(fileId, user); } function canAccess(uint256 fileId, address user) public view returns (bool) { require(files[fileId].owner != address(0), "file does not exist"); if (!files[fileId].isPrivate) return true; return files[fileId].owner == user || accessControl[fileId][user]; } }设计里有一个关键取舍:用自增fileCounter而不是 CID 字符串做 mapping key。字符串在 Solidity 中属于动态类型,作为 key 时哈希和存储成本都更高,而且无法支持“按顺序取文件列表”的语义。isPrivate标记文件是否私有;公开文件在canAccess里直接短路返回 true,不写权限表,省一次 storage 写操作。
block.timestamp是链上共识时间,不是前端传入的时间。前端时间可以被篡改,链上时间才能作为文件上传顺序的证据。这个细节体现了去中心化应用对可验证性的要求。
3. 本地复现:从克隆源码到跑通上传下载完整流程
复现这个项目不需要一开始就理解全部代码,先把环境跑起来,再沿着调用链看代码会顺很多。下面按我踩过坑的顺序整理。
3.1 环境准备:Node、Ganache、本地 IPFS 节点
最稳妥的组合是 Node.js + Truffle + Ganache + 本地 IPFS 节点。Truffle 负责编译部署 Solidity 合约,Ganache 提供测试链,IPFS 节点负责文件存储。所有东西都在本机跑,不需要连接公网。
# 全局安装 Truffle 与 Ganache CLI npm install -g truffle ganache # 启动本地测试链,监听 7545 端口,network id 固定为 5777 ganache -p 7545 -i 5777 # 另外开一个终端启动 IPFS 节点 ipfs daemon-p 7545指定测试链 RPC 端口,前端 Web3.js 要连接到这个地址;-i 5777固定 network id,如果不指定,Ganache 每次启动可能给出不同 id,后续 Truffle 部署产物里记录的 network key 就对不上。IPFS 启动后会同时监听两个端口:5001 用于 API 调用,8080 用于网关访问,这两个端口的职责完全不同。
启动完 IPFS 后,还要处理 CORS 配置,否则浏览器里请求 5001 端口会被同源策略拦截。常见做法是把 React 开发服务器的来源加进白名单:
ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["http://localhost:3000"]' ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '["PUT", "GET", "POST", "DELETE"]'第一条命令允许http://localhost:3000跨域调用 IPFS API,第二条放开对应的 HTTP 方法。如果之后换了前端端口,要同步更新第一个配置,否则会一直报跨域错误。
3.2 部署合约并连接 Web3.js
合约编译和网络配置是连在一起的。项目的truffle-config.js里需要把 development 网络指向 Ganache:
module.exports = { networks: { development: { host: '127.0.0.1', port: 7545, network_id: 5777 } }, compilers: { solc: { version: '0.8.0' } } }host建议写127.0.0.1而不是localhost,某些系统下 localhost 会解析到 IPv6::1,Ganache 只监听 IPv4 时会直接拒连。network_id要和ganache -i 5777一致,否则部署后前端按 id 找不到合约地址。配置好后执行:
truffle migrate --reset--reset强制重新部署,适合频繁改合约的调试阶段。部署完成后,终端会打印合约地址,这个地址要同步到前端的 Web3.js 配置中。如果源码包没有自动注入地址的脚本,这一步漏掉就会导致前端无法调用合约。
前端连接 Web3.js 时,我一般单独建一个web3config.js,从 Truffle 编译产物里读取地址和 ABI:
// web3config.js import Web3 from 'web3' import FileStorageArtifact from './contracts/FileStorage.json' const web3 = new Web3('http://127.0.0.1:7545') const contractAddress = FileStorageArtifact.networks['5777'].address const contractAbi = FileStorageArtifact.abi export { web3, contractAddress, contractAbi }FileStorageArtifact.networks['5777']是本次部署成功的关键。Truffle 会把每个网络上的部署地址写进 JSON 文件,如果 Ganache 启动参数里的 network id 和truffle-config.js不一致,这个 key 就取不到,前端拿不到地址。
3.3 上传文件的完整调用链:前端组件到 IPFS 再到合约
前端组件不必一次写完,核心上传逻辑可以精简成下面这段:
// UploadButton.js 中的核心逻辑 import React, { useState } from 'react' import { ipfs, storageContract, getAccount } from './storage' function UploadButton() { const [status, setStatus] = useState('') async function handleUpload(e) { const file = e.target.files[0] if (!file) return try { setStatus('uploading to IPFS...') const buffer = await file.arrayBuffer() const addResult = await ipfs.add({ path: file.name, content: Buffer.from(buffer) }) const cid = addResult.cid.toString() setStatus('writing index to chain...') const account = await getAccount() const receipt = await storageContract.methods .storeFile(cid, file.name, file.size, false) .send({ from: account }) const fileId = receipt.events.FileStored.returnValues.fileId setStatus(`done, file id = ${fileId}`) } catch (err) { console.error(err) setStatus('upload failed') } } return ( <div> <input type="file" onChange={handleUpload} /> <p>{status}</p> </div> ) }receipt.events.FileStored.returnValues.fileId是交易回执里提取事件参数的标准方式,它比上传后再调用files(id)查询更直接,也避免在并发环境下拿到错误的 id。from字段是当前钱包地址;在 Ganache 本地测试链里,默认第一个账户就是合约部署者,如果前端切换了 MetaMask 账户,后续grantAccess会因为 owner 不匹配而失败。
下载侧的逻辑同样要经过三步:先检查canAccess,再拼 IPFS 网关地址,最后拼 CID。很多复现会卡在第二步,因为 CID 版本不一致或网关端口写错,这部分细节放在避坑章节展开。
4. 合约实战:Solidity 里的函数权限、事件与 Gas 陷阱
数据结构只是骨架,真正决定平台能不能用的,是合约函数如何管理权限和事件。这一章聚焦最核心的storeFile、grantAccess和事件监听,以及它们背后的 gas 取舍。
4.1 登记文件与授予访问权限:两个最容易写错的函数
storeFile里有一个隐含假设:调用者就是文件 owner,所以函数内部不需要额外校验身份。但grantAccess就不同了,它必须校验msg.sender是否等于files[fileId].owner,否则任何人都能把一个文件授权给其他人,canAccess就形同虚设。
这里有一个 Solidity 新手最容易踩的坑:前端把当前账户地址作为参数传给合约,合约用这个参数判断身份。正确做法是只依赖msg.sender。前端地址只能作为数据,不能作为身份证明。如果你发现grantAccess一直报gas estimation failed,先用 Truffle console 或读函数把它跑一遍,大概率是地址参数传成了address(0)或 owner 判断失败。
另一个容易轻视的地方是storeFile的返回值和事件里的fileId。前端通过receipt.events.FileStored.returnValues.fileId拿到的值,和合约内部递增的fileCounter完全一致。不要在上传后立即调用files(id)去查,因为测试链上如果同时有其他交易插入,下一次调用可能拿到别的数据。
4.2 事件驱动的前端刷新机制
文件列表最适合用事件驱动,而不是轮询。以太坊事件本质上是合约日志,FileStored事件会通知所有监听它的 DApp 新增了一条文件记录。下面这段监听逻辑可以放进 React 组件的useEffect:
useEffect(() => { const loadHistory = async () => { const history = await storageContract.getPastEvents('FileStored', { fromBlock: 0, toBlock: 'latest' }) setFiles(history.map(event => event.returnValues)) } const listener = storageContract.events.FileStored() .on('data', event => { setFiles(prev => [event.returnValues, ...prev]) }) .on('error', console.error) loadHistory() return () => listener.unsubscribe() }, [])为什么既要getPastEvents又要events.FileStored()?因为页面刷新后,历史事件不会自动重新加载;而只有开启事件监听,新文件上传时列表才会实时更新。两者缺一不可。
注意 React StrictMode 在开发环境会重复执行 effect,所以listener.unsubscribe()必须放在清理函数里,否则组件会重复订阅,同一个文件在列表里出现两遍。这是一个非常隐蔽的副作用问题。
4.3 Gas 估算与存储模式:为什么合约里只能放证明
已经详细讲过了,但这里想从 gas 的实际计算角度补一句。Solidity 写入一个包含动态字符串的 struct,存储成本与字符串长度强相关。45 个字符的 CID v1 和一个几千字符的文件名,gas 消耗是完全不同的数量级。这个平台能跑通,就是因为它尽可能让链上保存的内容保持最小:一个 CID、一个文件名、一个 owner 地址、几个 uint 字段。
但即便这样,storeFile的 gas 也远高于普通转账。如果前端没有显式传gas,Web3.js 会调用estimateGas去估算,而估算失败时(比如 require 条件不满足)会直接抛错。这就是为什么前端写gas: 300000而不依赖默认值。另一个降 gas 的技巧是:公开文件在storeFile里不要写权限 mapping,直接在canAccess里短路返回 true。这样省下了一次 SSTORE 操作,合约成本能低一截。
5. 避坑指南:本地开发中三个高频故障与排查思路
复现这个平台时,真正让我花时间最多的不是合约编写,而是本地环境里那些“看起来不相关”的配置。下面三条排查记录按现象、原因、解决三个步骤写。
5.1 现象:文件上传成功但文件列表一直不刷新
我遇到过一次最诡异的场景:Ganache 里能看到交易回执,IPFS 网关也能正常访问文件,但页面文件列表始终是空的。打开浏览器的 Network 面板,发现/api/v0/add和合约调用都返回成功,问题出在前端状态没有更新。
原因有两个。一是上传完成后只把当次文件 push 到本地状态,没有监听合约事件;二是 React 组件在开发模式下重复挂载,事件监听被清理后没有重新拉取历史事件。解决方法是统一走事件驱动,用getPastEvents拉历史,再用events.FileStored()监听新事件。按照第 4.2 节的写法重置列表后,再上传第二个文件,列表会自动增加一行,不需要手动刷新页面。
5.2 现象:合约调用报gas estimation failed或invalid address
这类报错最容易让人以为是 gas 不够,但实际上大部分情况是参数或权限错误。有一次我在测试grantAccess,前端明明传了正确的 fileId 和地址,MetaMask 弹窗后还是失败。后来我打开 Truffle console 单独调用,发现msg.sender并不是文件 owner,原因是 Ganache 默认账户顺序变化,当前 MetaMask 账户不是上传文件时的那个账户。
另一个原因是前端把地址参数传成空字符串,Web3.js 会把空字符串解析成address(0)。合约里如果没有对user != address(0)做 require,交易本身会成功,但授权写入毫无意义。解决方法是前端先校验地址格式,合约里再补一层require(user != address(0), "zero address")。这样能把一半以上的地址错误提前拦截。
5.3 现象:上传用ipfs.add成功,下载时ipfs cat超时或 404
这个坑最隐蔽。前端的ipfs.add在本地 API 端口跑成功后,返回的 CID 可能是 v0 格式;但下载时通过网关localhost:8080/ipfs/CID访问,网关有时会重定向到 v1 格式的 CID。两者内容一致但字符串不同,前端如果直接拼接 URL,就会得到 404。
原因概括起来是 CID 版本不一致。解决方法是上传时强制指定cidVersion: 1,或者在下载链接组装前调用addResult.cid.toV1()做统一转换。另外还要确认本地节点已经 pin 这个文件;如果没有 pin,节点执行垃圾回收后文件就被删了,无论 CID 版本对不对都取不回内容。这三个问题都是我在复现时真实踩过的,每一步排查都能省下不少时间。
6. 进阶验证:用自动化脚本核验上传、事件、下载闭环
跑通界面只是第一步,真正让人放心的是把整条链路写成一个脚本,每次改完代码跑一遍,用机器判断“文件是否真的存进去、事件是否真的发出来、内容是否真的能取回来”。我一般单独写一个verify-flow.js,放在 scripts 目录下,用 Node.js 执行。
// verify-flow.js const { create } = require('ipfs-http-client') const Web3 = require('web3') const artifact = require('./build/contracts/FileStorage.json') async function main() { const ipfs = create({ host: '127.0.0.1', port: 5001 }) const web3 = new Web3('http://127.0.0.1:7545') const contract = new web3.eth.Contract(artifact.abi, artifact.networks['5777'].address) const [owner] = await web3.eth.getAccounts() // 用固定内容模拟文件,方便比对 const content = Buffer.from('verification file content') const added = await ipfs.add({ path: 'verify.txt', content }, { cidVersion: 1 }) const cid = added.cid.toString() // 写入合约,等待事件 const receipt = await contract.methods .storeFile(cid, 'verify.txt', content.length, false) .send({ from: owner, gas: 300000 }) const fileId = receipt.events.FileStored.returnValues.fileId // 从合约拿回 CID,再通过 IPFS 取回内容 const record = await contract.methods.files(fileId).call() const fetched = await ipfs.cat(record.cid) const contentMatch = Buffer.from(fetched).toString() === content.toString() console.log('contract cid matches:', record.cid === cid) console.log('content matches:', contentMatch) } main().catch(console.error)这个脚本验证了三个关键点:合约记录里的 CID 是否等于ipfs.add返回的 CID、事件参数能否正确取出 fileId、最终通过 IPFS 取回的内容是否与原始内容一致。如果 CID 版本不统一,第一条会直接输出 false。
我最早就是在这个脚本里发现自己的下载链接全部失效,原因就是ipfs.add返回 v0,而网关返回 v1。从那以后,我每次部署合约都会强制把cidVersion设为 1,并在验证脚本里固定检查record.cid === cid。这样一个不需要浏览器、不需要手点的脚本,能帮你把 80% 的链路问题提前暴露出来,也算给黑匣子装了个仪表盘。希望帮到你。
本文还有配套的精品资源,点击获取