当前位置: 首页 > news >正文

构建网页版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

各包的作用

  1. ipfs-http-client: 连接IPFS网络的客户端库

  2. @mui/material: Material-UI核心组件

  3. @mui/icons-material: Material-UI官方图标

  4. @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 允许跨域

步骤:

操作步骤:
  1. 关闭 IPFS 桌面应用(如果正在运行)

  2. 修改 IPFS 配置

  3. 打开终端(Windows 用 CMD/PowerShell,Mac/Linux 用 Terminal)

  4. 运行以下命令

  5. # 允许所有来源(开发环境用)
    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"]'
  6. 重新启动 IPFS 桌面应用
    (或通过命令行 ipfs daemon 启动)

  7. 方法 2:直接编辑配置文件

    配置文件路径:
  8. Windows:
    C:\Users\<你的用户名>\.ipfs\config

  9. Mac/Linux:
    ~/.ipfs/config

  10. 用文本编辑器(如 Notepad++、VS Code)打开配置文件

  11. 找到或添加以下字段:

    "API": {"HTTPHeaders": {"Access-Control-Allow-Origin": ["*"],"Access-Control-Allow-Methods": ["PUT", "POST", "GET"],"Access-Control-Allow-Headers": ["Authorization"]}
    }
  12. 保存文件后重启 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;

相关文章:

  • PostgreSQL 中 VACUUM FULL 对索引的影响
  • VMware Workstation 创建虚拟机并安装 Ubuntu 系统 的详细步骤指南
  • uniapp 实现时分秒 分别倒计时
  • 从零开始学Python游戏编程48-二维数组2
  • git did not exit cleanly (exit code 128) 已解决
  • 【uniapp】在UniApp中检测手机是否安装了某个应用
  • Canvas基础篇:图形绘制
  • 卫星变轨轨迹和推力模拟(单一引力源)MATLAB
  • AI驱动的决策智能系统(AIDP)和自然语言交互式分析
  • 金融风控的“天眼”:遥感技术的创新应用
  • SAP MM 定价程序步骤及细节
  • 第二章-科学计算库NumPy
  • 华为云汪维敏:AI赋能应用现代化,加速金融生产力跃升
  • vs2019编译occ7.9.0时,出现fatal error C1060: compiler is out of heap space
  • Mysql查询异常【Truncated incorrect INTEGER value】
  • vscode详细配置Go语言相关插件
  • win11 终端 安装ffmpeg 使用终端Scoop
  • OpenCV实战教程 第一部分:基础入门
  • Java List分页工具
  • 零部件设计行业如何在数字化转型中抓住机遇?
  • 澎湃回声丨23岁小伙“被精神病8年”续:今日将被移出“重精”管理系统
  • 史学巨擘的思想地图与学术路径——王汎森解析梁启超、陈寅恪、傅斯年
  • 辽宁辽阳火灾3名伤者无生命危险
  • 中方发布《不跪!》视频传递何种信息?外交部回应
  • 发出“美利坚名存实亡”呼号的卡尼,将带领加拿大走向何方?
  • 网警侦破特大“刷量引流”网络水军案:涉案金额达2亿余元