构建网页版IPFS去中心化网盘
前言:我把它命名为无限网盘 Unlimited network disks(ULND),可以实现简单的去中心化存储,其实实现起来并不难,还是依靠强大的IPFS,跟着我一步一步做就可以了。
第一步:准备开发环境
1.安装Node.js:
访问 Node.js官网
下载并安装LTS版本(如18.x)
安装完成后,打开终端/命令行,输入以下命令检查是否成功:
node -v
npm -v
【可选】如果在终端中运行失败,是Windows PowerShell 的执行策略(PowerShell Execution Policy)限制了脚本的运行,Windows系统默认限制PowerShell脚本执行以防止恶意脚本运行。npm在Windows上实际是通过npm.ps1(PowerShell脚本)运行的,所以受此限制。直接在命令提示符(CMD)中运行,或者以管理员身份运行PowerShell并更改执行策略:
查看当前执行策略:
Get-ExecutionPolicy
可能会显示Restricted(这是默认设置,禁止所有脚本运行)
更改执行策略:
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
输入Y确认更改
验证更改:
Get-ExecutionPolicy
现在应该显示RemoteSigned
完成npm操作后,可以改回严格模式:
Set-ExecutionPolicy Restricted -Scope CurrentUser
完成上述任一方法后,再次尝试:
npm -v
现在应该能正常显示npm版本号了。
2.安装代码编辑器:
推荐使用 VS Code(免费)或者Notepad++(免费)
第二步:创建React项目
1.打开终端/命令行,执行:
npx create-react-app ipfs-drive
你在哪里打开终端执行
cd ipfs-drive
就会装在哪里,或者使用完整路径(如装在D盘:
npx create-react-app D:\ipfs-drive
2.安装所需依赖:
npm install ipfs-http-client @mui/material @mui/icons-material @emotion/react @emotion/styled
各包的作用
-
ipfs-http-client
: 连接IPFS网络的客户端库 -
@mui/material
: Material-UI核心组件 -
@mui/icons-material
: Material-UI官方图标 -
@emotion/react
和@emotion/styled
: MUI v5的样式依赖
【可选】如果安装不了可以尝试使用淘宝镜像(中国大陆用户):
npm config set registry https://registry.npmmirror.comnpm install ipfs-http-client @mui/material @mui/icons-material @emotion/react @emotion/styled
第三步:创建IPFS连接文件
1.在src文件夹中新建ipfs.js文件
2.自建 IPFS 节点(持久化存储)
安装 IPFS 桌面应用发布 ·ipfs/ipfs-桌面
启动后修改 src/ipfs.js:
// 确保从 'ipfs-http-client' 导入 create 方法
import { create } from 'ipfs-http-client';// 自建节点配置(确保你的本地IPFS守护进程正在运行)
const ipfs = create({host: 'localhost',port: 5001,protocol: 'http'
});// 必须导出 ipfs 实例
export default ipfs;
特点:
文件保存在本地
需要保持节点在线才能访问
通过修改 config 文件可连接其他节点
第四步:修改主应用文件
1.打开src/App.js,清空原有内容
2.复制以下完整代码:
import React, { useState } from 'react';import {Button,Container,LinearProgress,List,ListItem,ListItemText,Typography,Box} from '@mui/material';import { CloudUpload, Download, ContentCopy } from '@mui/icons-material';import ipfs from './ipfs';function App() {const [files, setFiles] = useState([]);const [progress, setProgress] = useState(0);const handleFileUpload = async (event) => {const file = event.target.files[0];if (!file) return;try {const added = await ipfs.add(file, {progress: (prog) => setProgress((prog / file.size) * 100)});setFiles([...files, {cid: added.cid.toString(),name: file.name,size: (file.size / 1024).toFixed(2) + ' KB'}]);setProgress(0);alert('文件上传成功!');} catch (error) {console.error('上传出错:', error);alert('上传失败: ' + error.message);}};const downloadFile = async (cid, name) => {try {const chunks = [];for await (const chunk of ipfs.cat(cid)) {chunks.push(chunk);}const content = new Blob(chunks);const url = URL.createObjectURL(content);const link = document.createElement('a');link.href = url;link.download = name;link.click();} catch (error) {console.error('下载出错:', error);alert('下载失败: ' + error.message);}};return (<Container maxWidth="md" sx={{ mt: 4 }}><Typography variant="h3" gutterBottom>IPFS网盘</Typography><Box sx={{ mb: 3 }}><inputaccept="*"style={{ display: 'none' }}id="file-upload"type="file"onChange={handleFileUpload}/><label htmlFor="file-upload"><Buttonvariant="contained"color="primary"component="span"startIcon={<CloudUpload />}>上传文件</Button></label></Box>{progress > 0 && (<Box sx={{ width: '100%', mb: 2 }}><LinearProgress variant="determinate" value={progress} /><Typography variant="body2" align="center">上传中: {progress.toFixed(1)}%</Typography></Box>)}<List>{files.map((file, index) => (<ListItem key={index} divider><ListItemTextprimary={file.name}secondary={`CID: ${file.cid} | 大小: ${file.size}`}/><Buttonvariant="outlined"startIcon={<Download />}onClick={() => downloadFile(file.cid, file.name)}sx={{ mr: 1 }}>下载</Button><Buttonvariant="outlined"startIcon={<ContentCopy />}onClick={() => {navigator.clipboard.writeText(file.cid);alert('CID已复制!');}}>复制CID</Button></ListItem>))}</List>{files.length === 0 && (<Typography variant="body1" color="text.secondary" align="center">暂无文件,请上传您的第一个文件</Typography>)}</Container>);}export default App;
第五步:运行开发服务器
1.在终端执行:
npm start
2.浏览器会自动打开 http://localhost:3000
3.你应该能看到一个简洁的文件上传界面
第六步:测试功能
1.上传文件:
点击"上传文件"按钮
选择任意文件
观察上传进度条
上传成功后文件会显示在列表中
2.下载文件:
在文件列表中点击"下载"按钮
检查下载的文件是否完整
3.复制CID:
点击"复制CID"按钮
粘贴到文本编辑器验证是否复制成功
【常见错误】由于 CORS (跨域资源共享) 限制导致的,你的 React 应用运行在 http://localhost:3000
,而 IPFS API 运行在 http://127.0.0.1:5001
,浏览器出于安全考虑阻止了跨域请求。以下是完整的解决方案:
方法一:配置 IPFS 允许跨域
步骤:
操作步骤:
-
关闭 IPFS 桌面应用(如果正在运行)
-
修改 IPFS 配置:
-
打开终端(Windows 用 CMD/PowerShell,Mac/Linux 用 Terminal)
-
运行以下命令:
-
# 允许所有来源(开发环境用) ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["*"]'# 允许所有方法 ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '["PUT", "POST", "GET"]'# 允许自定义头 ipfs config --json API.HTTPHeaders.Access-Control-Allow-Headers '["Authorization"]'
-
重新启动 IPFS 桌面应用
(或通过命令行ipfs daemon
启动) -
方法 2:直接编辑配置文件
配置文件路径:
-
Windows:
C:\Users\<你的用户名>\.ipfs\config
-
Mac/Linux:
~/.ipfs/config
-
用文本编辑器(如 Notepad++、VS Code)打开配置文件
-
找到或添加以下字段:
"API": {"HTTPHeaders": {"Access-Control-Allow-Origin": ["*"],"Access-Control-Allow-Methods": ["PUT", "POST", "GET"],"Access-Control-Allow-Headers": ["Authorization"]} }
-
保存文件后重启 IPFS 守护进程
第七步:部署到网络
运行以下命令:
npm run buildipfs add -r build
记下最后输出的目录CID(如Qm...)
通过任意IPFS网关访问,如:
https://ipfs.io/ipfs/YOUR_CID_HERE
一个简单的去中心化网盘就做好啦,接下来就是完善了,主要修改src/APP.js 文件
import React, { useState, useEffect } from 'react';
import {Button, Container, LinearProgress, List, ListItem, ListItemText,Typography, Box, Chip, Dialog, DialogContent, DialogActions, Snackbar, Alert
} from '@mui/material';
import {CloudUpload, Download, ContentCopy, CreateNewFolder,Lock, LockOpen, Image as ImageIcon, Folder, Refresh
} from '@mui/icons-material';
import ipfs from './ipfs';// 持久化存储键名
const STORAGE_KEY = 'ipfs_drive_data_v2';function App() {const [files, setFiles] = useState([]);const [folders, setFolders] = useState([]);const [progress, setProgress] = useState(0);const [currentPath, setCurrentPath] = useState('');const [previewImage, setPreviewImage] = useState(null);const [loading, setLoading] = useState(false);const [error, setError] = useState(null);const [initialized, setInitialized] = useState(false);// 初始化加载数据useEffect(() => {const loadPersistedData = async () => {try {// 1. 从本地存储加载基础信息const savedData = localStorage.getItem(STORAGE_KEY);if (savedData) {const { files: savedFiles, folders: savedFolders, path } = JSON.parse(savedData);setFiles(savedFiles || []);setFolders(savedFolders || []);setCurrentPath(path || '');}// 2. 从IPFS加载实际数据await refreshData();setInitialized(true);} catch (err) {setError('初始化失败: ' + err.message);}};loadPersistedData();}, []);// 数据持久化useEffect(() => {if (initialized) {localStorage.setItem(STORAGE_KEY, JSON.stringify({files: files.filter(f => !f.isDirectory),folders,path: currentPath}));}}, [files, folders, currentPath, initialized]);// 加密函数const encryptData = async (data, password) => {const encoder = new TextEncoder();const keyMaterial = await window.crypto.subtle.importKey('raw',encoder.encode(password),{ name: 'PBKDF2' },false,['deriveBits']);const salt = window.crypto.getRandomValues(new Uint8Array(16));const keyBits = await window.crypto.subtle.deriveBits({name: 'PBKDF2',salt,iterations: 100000,hash: 'SHA-256'},keyMaterial,256);const iv = window.crypto.getRandomValues(new Uint8Array(12));const cryptoKey = await window.crypto.subtle.importKey('raw',keyBits,{ name: 'AES-GCM' },false,['encrypt']);const encrypted = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv },cryptoKey,data);return { encrypted, iv, salt };};// 解密函数const decryptData = async (encryptedData, password, iv, salt) => {try {const encoder = new TextEncoder();const keyMaterial = await window.crypto.subtle.importKey('raw',encoder.encode(password),{ name: 'PBKDF2' },false,['deriveBits']);const keyBits = await window.crypto.subtle.deriveBits({name: 'PBKDF2',salt,iterations: 100000,hash: 'SHA-256'},keyMaterial,256);const cryptoKey = await window.crypto.subtle.importKey('raw',keyBits,{ name: 'AES-GCM' },false,['decrypt']);return await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv },cryptoKey,encryptedData);} catch (err) {throw new Error('解密失败: 密码错误或数据损坏');}};// 文件上传函数const handleFileUpload = async (event) => {const file = event.target.files[0];if (!file) return;setLoading(true);try {const shouldEncrypt = window.confirm('是否需要加密此文件?');let fileData = await file.arrayBuffer();let encryptionInfo = null;if (shouldEncrypt) {const password = prompt('请输入加密密码');if (!password) return;encryptionInfo = await encryptData(fileData, password);fileData = encryptionInfo.encrypted;}// 上传文件内容const added = await ipfs.add({ content: fileData },{ progress: (prog) => setProgress((prog / fileData.byteLength) * 100),pin: true});// 如果是文件夹内上传,更新目录结构const uploadPath = currentPath ? `${currentPath}/${file.name}` : file.name;if (currentPath) {await ipfs.files.cp(`/ipfs/${added.cid}`, `/${uploadPath}`);}// 存储元数据const metadata = {originalName: file.name,mimeType: file.type,size: file.size,encrypted: shouldEncrypt,timestamp: new Date().toISOString()};const metadataCid = (await ipfs.add(JSON.stringify(metadata))).cid.toString();await ipfs.pin.add(metadataCid);const newFile = {cid: added.cid.toString(),name: file.name,size: (file.size / 1024).toFixed(2) + ' KB',encrypted: !!encryptionInfo,path: uploadPath,isDirectory: false,isImage: file.type.startsWith('image/'),encryptionInfo,metadataCid};setFiles(prev => [...prev, newFile]);setError(null);alert(`文件${encryptionInfo ? '(加密)' : ''}上传成功!`);} catch (err) {console.error('上传出错:', err);setError('上传失败: ' + err.message);} finally {setLoading(false);setProgress(0);}};// 处理文件下载const handleDownload = async (file) => {try {setLoading(true);let blob;if (file.encrypted) {// 加密文件处理const password = prompt('请输入解密密码');if (!password) return;const chunks = [];for await (const chunk of ipfs.cat(file.cid)) {chunks.push(chunk);}const encryptedData = new Uint8Array(chunks.reduce((acc, chunk) => [...acc, ...new Uint8Array(chunk)], []));const decrypted = await decryptData(encryptedData,password,file.encryptionInfo.iv,file.encryptionInfo.salt);blob = new Blob([decrypted], { type: 'application/octet-stream' });} else {// 普通文件处理const chunks = [];for await (const chunk of ipfs.cat(file.cid)) {chunks.push(chunk);}blob = new Blob(chunks, { type: 'application/octet-stream' });}// 创建下载链接const url = URL.createObjectURL(blob);const link = document.createElement('a');link.href = url;link.download = file.name;document.body.appendChild(link);link.click();setTimeout(() => {document.body.removeChild(link);URL.revokeObjectURL(url);}, 100);} catch (err) {console.error('下载出错:', err);setError(err.message.includes('解密失败') ? err.message : '下载失败: ' + err.message);} finally {setLoading(false);}};// 处理图片预览const handlePreview = async (file) => {try {setLoading(true);const chunks = [];for await (const chunk of ipfs.cat(file.cid)) {chunks.push(chunk);}let fileData = new Uint8Array(chunks.reduce((acc, chunk) => [...acc, ...new Uint8Array(chunk)], []));if (file.encrypted) {const password = prompt('请输入解密密码');if (!password) return;fileData = new Uint8Array(await decryptData(fileData,password,file.encryptionInfo.iv,file.encryptionInfo.salt));}const blob = new Blob([fileData], { type: 'image/*' });const reader = new FileReader();reader.onload = () => {setPreviewImage({url: reader.result,name: file.name,blob});};reader.readAsDataURL(blob);} catch (err) {console.error('预览出错:', err);setError(err.message.includes('解密失败') ? err.message : '预览失败: ' + err.message);} finally {setLoading(false);}};// 创建文件夹const createFolder = async () => {const folderName = prompt('请输入文件夹名称');if (!folderName) return;try {const path = currentPath ? `${currentPath}/${folderName}` : folderName;await ipfs.files.mkdir(`/${path}`);const newFolder = {cid: (await ipfs.files.stat(`/${path}`)).cid.toString(),name: folderName,path,isDirectory: true};setFolders(prev => [...prev, newFolder]);setError(null);} catch (err) {console.error('创建文件夹失败:', err);setError('创建文件夹失败: ' + err.message);}};// 加载目录内容const loadDirectory = async (folder) => {try {setLoading(true);const contents = [];const path = folder.path || folder.cid;for await (const entry of ipfs.files.ls(`/${path}`)) {// 尝试加载元数据let originalName = entry.name;let isImage = false;try {const metadata = await loadMetadata(entry.cid.toString());if (metadata) {originalName = metadata.originalName || originalName;isImage = metadata.mimeType?.startsWith('image/') || false;}} catch {}contents.push({cid: entry.cid.toString(),name: originalName,size: (entry.size / 1024).toFixed(2) + ' KB',isDirectory: entry.type === 'directory',path: `${path}/${entry.name}`,isImage});}setCurrentPath(path);setFiles(contents);setError(null);} catch (err) {console.error('目录加载失败:', err);setError('加载目录失败: ' + err.message);} finally {setLoading(false);}};// 加载元数据const loadMetadata = async (cid) => {try {const chunks = [];for await (const chunk of ipfs.cat(cid)) {chunks.push(chunk);}return JSON.parse(new TextDecoder().decode(new Uint8Array(chunks)));} catch {return null;}};// 刷新数据const refreshData = async () => {try {setLoading(true);const updatedFiles = [];const updatedFolders = [];// 1. 加载所有固定文件for await (const { cid } of ipfs.pin.ls()) {try {// 2. 获取文件状态const stats = await ipfs.files.stat(`/ipfs/${cid}`);// 3. 尝试加载元数据const metadata = await loadMetadata(cid.toString());if (stats.type === 'file') {updatedFiles.push({cid: cid.toString(),name: metadata?.originalName || cid.toString(),size: (stats.size / 1024).toFixed(2) + ' KB',isDirectory: false,isImage: metadata?.mimeType?.startsWith('image/') || false,encrypted: metadata?.encrypted || false});} else if (stats.type === 'directory') {updatedFolders.push({cid: cid.toString(),name: metadata?.originalName || cid.toString(),isDirectory: true});}} catch (err) {console.warn(`无法处理 ${cid}:`, err);}}setFiles(updatedFiles);setFolders(updatedFolders);setError(null);} catch (err) {console.error('刷新数据失败:', err);setError('刷新数据失败: ' + err.message);} finally {setLoading(false);}};return (<Container maxWidth="md" sx={{ mt: 4 }}><Typography variant="h3" gutterBottom>IPFS网盘 {currentPath && `- ${currentPath.split('/').pop()}`}</Typography>{/* 操作栏 */}<Box sx={{ mb: 3, display: 'flex', gap: 2, flexWrap: 'wrap' }}><inputaccept="*"style={{ display: 'none' }}id="file-upload"type="file"onChange={handleFileUpload}disabled={loading}/><label htmlFor="file-upload"><Button variant="contained" startIcon={<CloudUpload />} component="span" disabled={loading}>上传文件</Button></label><Button onClick={createFolder} startIcon={<CreateNewFolder />} disabled={loading}>新建文件夹</Button><Button onClick={refreshData} startIcon={<Refresh />} disabled={loading}>刷新数据</Button></Box>{/* 显示当前路径 */}{currentPath && (<Box sx={{ mb: 2 }}><Button onClick={() => setCurrentPath('')} size="small" startIcon={<Folder />}>返回根目录</Button><Typography variant="body2" sx={{ mt: 1 }}>当前路径: <code>{currentPath}</code></Typography></Box>)}{/* 文件夹列表 */}{folders.length > 0 && !currentPath && (<Box sx={{ mb: 3 }}><Typography variant="h6">文件夹</Typography><Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>{folders.map((folder, i) => (<Chipkey={i}icon={<Folder />}label={folder.name}onClick={() => loadDirectory(folder)}sx={{ cursor: 'pointer' }}color="primary"/>))}</Box></Box>)}{/* 文件列表 */}<List>{files.map((file, i) => (<ListItem key={i} divider><ListItemTextprimary={<Box sx={{ display: 'flex', alignItems: 'center' }}>{file.isDirectory ? <Folder sx={{ mr: 1 }} /> : null}{file.name}{file.encrypted && <Lock color="warning" sx={{ ml: 1, fontSize: '1rem' }} />}</Box>}secondary={<><span>CID: {file.cid}</span><br /><span>大小: {file.size}</span></>}/><Box sx={{ display: 'flex', gap: 1 }}>{file.isDirectory ? (<Buttonsize="small"variant="outlined"startIcon={<Folder />}onClick={() => loadDirectory(file)}>打开</Button>) : (<><Buttonsize="small"variant="outlined"startIcon={file.encrypted ? <LockOpen /> : <Download />}onClick={() => handleDownload(file)}disabled={loading}>{file.encrypted ? '解密下载' : '下载'}</Button>{file.isImage && (<Buttonsize="small"variant="outlined"startIcon={<ImageIcon />}onClick={() => handlePreview(file)}disabled={loading}>预览</Button>)}</>)}</Box></ListItem>))}</List>{/* 空状态提示 */}{files.length === 0 && (<Typography color="text.secondary" align="center" sx={{ py: 4 }}>{currentPath ? '此文件夹为空' : '暂无文件,请上传文件或创建文件夹'}</Typography>)}{/* 图片预览对话框 */}<Dialog open={!!previewImage} onClose={() => setPreviewImage(null)} maxWidth="md" fullWidth><DialogContent><imgsrc={previewImage?.url}alt="预览"style={{ maxWidth: '100%', maxHeight: '70vh',display: 'block',margin: '0 auto'}}/></DialogContent><DialogActions><Button onClick={() => setPreviewImage(null)}>关闭</Button><Button onClick={() => {if (previewImage?.blob) {const url = URL.createObjectURL(previewImage.blob);const link = document.createElement('a');link.href = url;link.download = previewImage.name;document.body.appendChild(link);link.click();setTimeout(() => {document.body.removeChild(link);URL.revokeObjectURL(url);}, 100);}}}color="primary"startIcon={<Download />}>下载图片</Button></DialogActions></Dialog>{/* 全局加载状态 */}{loading && (<Box sx={{position: 'fixed',top: 0, left: 0, right: 0, bottom: 0,bgcolor: 'rgba(0,0,0,0.5)',display: 'flex',justifyContent: 'center',alignItems: 'center',zIndex: 9999}}><Box sx={{bgcolor: 'background.paper',p: 4,borderRadius: 2,textAlign: 'center'}}><Typography variant="h6" gutterBottom>处理中,请稍候...</Typography><LinearProgress /></Box></Box>)}{/* 错误提示 */}<Snackbaropen={!!error}autoHideDuration={6000}onClose={() => setError(null)}anchorOrigin={{ vertical: 'top', horizontal: 'center' }}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar></Container>);
}export default App;