前言:我把它命名為無限網盤 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;