From 61117803e11bfa5dea204288666762846c52a1a6 Mon Sep 17 00:00:00 2001 From: Team Date: Tue, 10 Mar 2026 07:27:10 +0000 Subject: [PATCH] =?UTF-8?q?=E5=9B=A2=E9=98=9F=E5=B7=A5=E4=BD=9C=E8=BF=9B?= =?UTF-8?q?=E5=B1=95:=20-=20=E5=90=8E=E7=AB=AF:=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20API=20(=E4=B8=8A=E4=BC=A0/=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD/=E5=88=A0=E9=99=A4/=E5=88=9B=E5=BB=BA=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=B9)=20-=20=E5=89=8D=E7=AB=AF:=20Electron=20?= =?UTF-8?q?=E4=B8=BB=E8=BF=9B=E7=A8=8B=20+=20React=20=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=20-=20=E6=B5=8B=E8=AF=95:=20=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=92=8C=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/files.js | 169 ++++++++++++++++++++++++++++++++ frontend/src/main/index.js | 66 +++++++++++++ frontend/src/main/preload.js | 18 ++++ frontend/src/renderer/App.jsx | 178 ++++++++++++++++++++++++++++++++++ tests/backend/auth.test.js | 62 ++++++++++++ tests/backend/files.test.js | 47 +++++++++ 6 files changed, 540 insertions(+) create mode 100644 backend/src/routes/files.js create mode 100644 frontend/src/main/index.js create mode 100644 frontend/src/main/preload.js create mode 100644 frontend/src/renderer/App.jsx create mode 100644 tests/backend/auth.test.js create mode 100644 tests/backend/files.test.js diff --git a/backend/src/routes/files.js b/backend/src/routes/files.js new file mode 100644 index 0000000..0866e9b --- /dev/null +++ b/backend/src/routes/files.js @@ -0,0 +1,169 @@ +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const db = require('../db'); + +const router = express.Router(); + +// Configure multer for file upload +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const uploadDir = path.join(__dirname, '../../uploads'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + crypto.randomBytes(6).toString('hex'); + cb(null, uniqueSuffix + path.extname(file.originalname)); + } +}); + +const upload = multer({ + storage, + limits: { fileSize: 100 * 1024 * 1024 } // 100MB limit +}); + +// Get file list +router.get('/', (req, res) => { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) return res.status(401).json({ error: 'No token' }); + + try { + const jwt = require('jsonwebtoken'); + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'clouddisk-secret-key'); + const parentId = req.query.parentId || null; + + const files = db.query( + 'SELECT * FROM files WHERE user_id = ? AND parent_id IS ? AND deleted_at IS NULL ORDER BY is_folder DESC, name ASC', + [decoded.userId, parentId] + ); + + res.json({ files }); + } catch (error) { + res.status(401).json({ error: 'Invalid token' }); + } +}); + +// Upload file +router.post('/upload', upload.single('file'), (req, res) => { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) return res.status(401).json({ error: 'No token' }); + + try { + const jwt = require('jsonwebtoken'); + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'clouddisk-secret-key'); + + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + // Calculate file hash (simplified) + const hash = crypto.createHash('md5').update(req.file.filename).digest('hex'); + + // Check if file already exists (for deduplication) + const existing = db.query( + 'SELECT id FROM files WHERE user_id = ? AND hash = ? AND name = ?', + [decoded.userId, hash, req.file.originalname] + ); + + let fileId; + if (existing.length > 0) { + // File exists, update + fileId = existing[0].id; + db.run( + 'UPDATE files SET size = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [req.file.size, fileId] + ); + } else { + // Insert new file + const result = db.run( + 'INSERT INTO files (user_id, name, type, size, path, hash, is_folder) VALUES (?, ?, ?, ?, ?, ?, ?)', + [decoded.userId, req.file.originalname, path.extname(req.file.originalname).slice(1), req.file.size, req.file.path, hash, 0] + ); + fileId = result.lastInsertRowid; + } + + // Update user storage + db.run( + 'UPDATE users SET storage_used = storage_used + ? WHERE id =', + [req.file.size, decoded.userId] + ); + + res.json({ success: true, fileId, filename: req.file.originalname }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Download file +router.get('/:id/download', (req, res) => { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) return res.status(401).json({ error: 'No token' }); + + try { + const jwt = require('jsonwebtoken'); + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'clouddisk-secret-key'); + + const file = db.query( + 'SELECT * FROM files WHERE id = ? AND user_id = ? AND is_folder = 0', + [req.params.id, decoded.userId] + ); + + if (file.length === 0) { + return res.status(404).json({ error: 'File not found' }); + } + + res.download(file[0].path, file[0].name); + } catch (error) { + res.status(401).json({ error: 'Invalid token' }); + } +}); + +// Delete file +router.delete('/:id', (req, res) => { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) return res.status(401).json({ error: 'No token' }); + + try { + const jwt = require('jsonwebtoken'); + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'clouddisk-secret-key'); + + // Soft delete + db.run( + 'UPDATE files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', + [req.params.id, decoded.userId] + ); + + res.json({ success: true }); + } catch (error) { + res.status(401).json({ error: 'Invalid token' }); + } +}); + +// Create folder +router.post('/folder', (req, res) => { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) return res.status(401).json({ error: 'No token' }); + + try { + const jwt = require('jsonwebtoken'); + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'clouddisk-secret-key'); + + const { name, parentId } = req.body; + + const result = db.run( + 'INSERT INTO files (user_id, name, parent_id, is_folder) VALUES (?, ?, ?, ?)', + [decoded.userId, name, parentId || null, 1] + ); + + res.json({ success: true, folderId: result.lastInsertRowid }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/frontend/src/main/index.js b/frontend/src/main/index.js new file mode 100644 index 0000000..60f3af7 --- /dev/null +++ b/frontend/src/main/index.js @@ -0,0 +1,66 @@ +const { app, BrowserWindow, ipcMain, dialog } = require('electron'); +const path = require('path'); + +let mainWindow; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, 'preload.js') + } + }); + + // Load the app + if (process.env.NODE_ENV === 'development') { + mainWindow.loadURL('http://localhost:3000'); + mainWindow.webContents.openDevTools(); + } else { + mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')); + } + + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +app.whenReady().then(createWindow); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } +}); + +// IPC handlers +ipcMain.handle('select-file', async () => { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openFile', 'multiSelections'] + }); + return result.filePaths; +}); + +ipcMain.handle('select-folder', async () => { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openDirectory'] + }); + return result.filePaths[0]; +}); + +ipcMain.handle('show-message', async (event, { title, message, type }) => { + const { dialog } = require('electron'); + return dialog.showMessageBox(mainWindow, { + type: type || 'info', + title: title || 'CloudDisk', + message: message + }); +}); diff --git a/frontend/src/main/preload.js b/frontend/src/main/preload.js new file mode 100644 index 0000000..9be8f53 --- /dev/null +++ b/frontend/src/main/preload.js @@ -0,0 +1,18 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('electron', { + selectFile: () => ipcRenderer.invoke('select-file'), + selectFolder: () => ipcRenderer.invoke('select-folder'), + showMessage: (options) => ipcRenderer.invoke('show-message', options), + + // File operations + uploadFile: (filePath) => { + const formData = new FormData(); + formData.append('file', require('fs').createReadStream(filePath)); + return fetch('/api/files/upload', { + method: 'POST', + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }, + body: formData + }); + } +}); diff --git a/frontend/src/renderer/App.jsx b/frontend/src/renderer/App.jsx new file mode 100644 index 0000000..0c08ca2 --- /dev/null +++ b/frontend/src/renderer/App.jsx @@ -0,0 +1,178 @@ +import React, { useState, useEffect } from 'react'; +import { ConfigProvider, Layout, Menu, Button, Upload, Table, Breadcrumb, Input } from 'antd'; +import { + UploadOutlined, + FolderOutlined, + FileOutlined, + HomeOutlined, + DeleteOutlined, + DownloadOutlined, + ShareAltOutlined +} from '@ant-design/icons'; + +const { Header, Sider, Content } = Layout; + +function App() { + const [user, setUser] = useState(null); + const [files, setFiles] = useState([]); + const [currentPath, setCurrentPath] = useState([]); + const [isLoggedIn, setIsLoggedIn] = useState(false); + + const login = async (username, password) => { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + const data = await response.json(); + if (data.token) { + localStorage.setItem('token', data.token); + setUser(data.user); + setIsLoggedIn(true); + loadFiles(); + } + return data; + }; + + const loadFiles = async (parentId = null) => { + const token = localStorage.getItem('token'); + const url = parentId ? `/api/files?parentId=${parentId}` : '/api/files'; + const response = await fetch(url, { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await response.json(); + setFiles(data.files || []); + }; + + const handleUpload = async (file) => { + const token = localStorage.getItem('token'); + const formData = new FormData(); + formData.append('file', file); + + await fetch('/api/files/upload', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData + }); + + loadFiles(); + }; + + const handleCreateFolder = async () => { + const name = prompt('请输入文件夹名称:'); + if (!name) return; + + const token = localStorage.getItem('token'); + await fetch('/api/files/folder', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ name }) + }); + + loadFiles(); + }; + + const handleDelete = async (fileId) => { + const token = localStorage.getItem('token'); + await fetch(`/api/files/${fileId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + loadFiles(); + }; + + const columns = [ + { + title: '名称', + dataIndex: 'name', + key: 'name', + render: (text, record) => ( + + {record.is_folder ? : } {text} + + ) + }, + { + title: '大小', + dataIndex: 'size', + key: 'size', + render: (size) => size ? `${(size / 1024 / 1024).toFixed(2)} MB` : '-' + }, + { + title: '修改时间', + dataIndex: 'updated_at', + key: 'updated_at' + }, + { + title: '操作', + key: 'action', + render: (_, record) => ( + + + + + + + + + + + + + ); +} + +function LoginPage({ onLogin }) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + return ( +
+
+

CloudDisk

+ setUsername(e.target.value)} style={{ marginBottom: '16px' }} /> + setPassword(e.target.value)} style={{ marginBottom: '16px' }} /> + +
+
+ ); +} + +export default App; diff --git a/tests/backend/auth.test.js b/tests/backend/auth.test.js new file mode 100644 index 0000000..eb37f0a --- /dev/null +++ b/tests/backend/auth.test.js @@ -0,0 +1,62 @@ +const request = require('supertest'); +const app = require('../../backend/src/index'); + +describe('Auth API', () => { + const testUser = { + username: 'testuser' + Date.now(), + password: 'test123', + email: 'test@example.com' + }; + + describe('POST /api/auth/register', () => { + it('should register a new user', async () => { + const response = await request(app) + .post('/api/auth/register') + .send(testUser); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('token'); + expect(response.body).toHaveProperty('userId'); + }); + + it('should not register duplicate username', async () => { + // First registration + await request(app) + .post('/api/auth/register') + .send(testUser); + + // Duplicate registration + const response = await request(app) + .post('/api/auth/register') + .send(testUser); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + }); + + describe('POST /api/auth/login', () => { + it('should login with valid credentials', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ + username: testUser.username, + password: testUser.password + }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('token'); + }); + + it('should fail with invalid credentials', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ + username: testUser.username, + password: 'wrongpassword' + }); + + expect(response.status).toBe(401); + }); + }); +}); diff --git a/tests/backend/files.test.js b/tests/backend/files.test.js new file mode 100644 index 0000000..ea7ea9d --- /dev/null +++ b/tests/backend/files.test.js @@ -0,0 +1,47 @@ +const request = require('supertest'); +const app = require('../../backend/src/index'); + +describe('Files API', () => { + let token; + const testUser = { + username: 'filetest' + Date.now(), + password: 'test123' + }; + + beforeAll(async () => { + // Register and login + await request(app) + .post('/api/auth/register') + .send(testUser); + + const loginRes = await request(app) + .post('/api/auth/login') + .send(testUser); + + token = loginRes.body.token; + }); + + describe('GET /api/files', () => { + it('should get file list', async () => { + const response = await request(app) + .get('/api/files') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('files'); + expect(Array.isArray(response.body.files)).toBe(true); + }); + }); + + describe('POST /api/files/folder', () => { + it('should create a new folder', async () => { + const response = await request(app) + .post('/api/files/folder') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'Test Folder' }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('success', true); + }); + }); +});