Initial commit - 玩玩云文件管理系统 v1.0.0

- 完整的前后端代码
- 支持本地存储和SFTP存储
- 文件分享功能
- 上传工具源代码
- 完整的部署文档
- Nginx配置模板

技术栈:
- 后端: Node.js + Express + SQLite
- 前端: Vue.js 3 + Axios
- 存储: 本地存储 / SFTP远程存储
This commit is contained in:
WanWanYun
2025-11-10 21:50:16 +08:00
commit 0f133962dc
36 changed files with 32178 additions and 0 deletions

67
.gitignore vendored Normal file
View File

@@ -0,0 +1,67 @@
# 依赖
node_modules/
__pycache__/
*.pyc
*.pyo
# 数据库
*.db
*.db-journal
*.sqlite
*.sqlite3
# 临时文件
backend/uploads/
storage/ # 本地存储数据
*.log
.DS_Store
Thumbs.db
# 环境配置
.env
!backend/.env.example
# SSL证书
certbot/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# 上传工具构建产物
upload-tool/dist/
upload-tool/build/
upload-tool/__pycache__/
upload-tool/config.json
upload-tool/*.spec
# 备份文件
*.bak
*.backup
*.old
# 操作系统
.DS_Store
.Spotlight-V100
.Trashes
ehthumbs.db
Desktop.ini
# 压缩文件
*.zip
*.tar
*.gz
*.rar
*.7z
# npm/yarn
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json.bak
# Claude配置
.claude/

183
DEPLOY.md Normal file
View File

@@ -0,0 +1,183 @@
# 玩玩云 - 部署指南
## 🚀 快速部署3分钟完成
### 第一步:上传项目到服务器
```bash
# 方法1: 使用scp上传
scp -r ftp-web-manager root@服务器IP:/var/www/
# 方法2: 使用FTP工具上传到 /var/www/ 目录
```
### 第二步SSH登录服务器
```bash
ssh root@服务器IP
```
### 第三步:一键部署
```bash
cd /var/www/ftp-web-manager
bash deploy.sh
```
部署脚本会自动:
- ✅ 检查Docker环境
- ✅ 创建必要目录
- ✅ 构建Docker镜像
- ✅ 启动所有服务
- ✅ 显示访问信息
### 第四步:访问系统
打开浏览器访问:
```
http://服务器IP:8080
```
使用默认账号登录:
```
用户名: admin
密码: admin123
```
**⚠️ 重要:首次登录后立即修改密码!**
---
## 📋 环境要求
- Docker 20.10.0+
- Docker Compose 2.0.0+
- 最低 1GB 内存(推荐 2GB+
- Linux 系统Ubuntu/Debian/CentOS
---
## 🔧 手动部署(如果自动脚本失败)
```bash
# 1. 进入项目目录
cd /var/www/ftp-web-manager
# 2. 创建必要目录
mkdir -p certbot/conf certbot/www backend/uploads
# 3. 构建并启动
docker-compose up --build -d
# 4. 查看日志
docker-compose logs -f
```
---
## ✅ 部署验证
检查容器状态:
```bash
docker-compose ps
```
应该看到3个容器都是 "Up" 状态:
- wanwanyun-backend
- wanwanyun-frontend
- wanwanyun-certbot
查看后端日志:
```bash
docker-compose logs backend
```
应该看到:
```
数据库初始化完成
默认管理员账号已创建
玩玩云已启动
```
---
## 🛑 停止服务
```bash
cd /var/www/ftp-web-manager
docker-compose down
```
---
## 🔄 重启服务
```bash
cd /var/www/ftp-web-manager
docker-compose restart
```
---
## 📦 更新代码
```bash
cd /var/www/ftp-web-manager
git pull # 或重新上传文件
docker-compose up --build -d
```
---
## ❓ 常见问题
### Q: 端口8080被占用怎么办
修改 docker-compose.yml 中的端口映射:
```yaml
ports:
- "8081:80" # 改为8081或其他端口
```
### Q: Docker容器启动失败
```bash
# 查看详细日志
docker-compose logs backend
# 重新构建
docker-compose down
docker-compose up --build -d
```
### Q: 忘记管理员密码怎么办?
删除数据库文件重新初始化:
```bash
docker-compose down
rm backend/ftp-manager.db
docker-compose up -d
```
### Q: 如何配置HTTPS
参考主README.md中的SSL配置章节。
---
## 📞 获取帮助
- 查看详细文档: README.md
- 查看部署检查报告: 桌面上的检查报告文件
- 查看对话历史: 桌面上的对话总结文件
---
**部署成功后,记得:**
1. ✅ 修改admin密码
2. ✅ 配置SFTP连接
3. ✅ 设置JWT密钥backend/.env
4. ✅ 配置HTTPS生产环境
5. ✅ 定期备份数据库
祝您使用愉快!☁️

189
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,189 @@
# 玩玩云部署指南
## 快速部署
### 1. 基础部署Docker Compose
```bash
# 克隆项目
git clone <repository-url>
cd ftp-web-manager
# 启动服务
docker-compose up -d
```
服务将在以下端口运行:
- Frontend (Nginx): 8080 (HTTP), 8443 (HTTPS)
- Backend (Node.js): 40001
### 2. 如果使用宿主机Nginx作为反向代理
如果你在宿主机上使用Nginx作为SSL终止/反向代理推荐用于生产环境需要在Nginx配置中添加大文件上传支持。
#### 2.1 创建Nginx配置文件
创建 `/etc/nginx/sites-available/wanwanyun.conf`(或对应的配置目录):
```nginx
server {
listen 80;
server_name your-domain.com;
# HTTP重定向到HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL证书配置使用Let's Encrypt或其他证书
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# 反向代理到Docker容器
location / {
# ⚠️ 重要设置最大上传文件大小为5GB
client_max_body_size 5G;
# ⚠️ 重要大文件上传超时设置1小时
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 3600s;
# 代理到Docker容器的8080端口
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
#### 2.2 应用配置
```bash
# 启用站点配置
ln -s /etc/nginx/sites-available/wanwanyun.conf /etc/nginx/sites-enabled/
# 测试Nginx配置
nginx -t
# 重新加载Nginx
nginx -s reload
```
### 3. 宝塔面板用户
如果使用宝塔面板,配置文件通常在:
- `/www/server/panel/vhost/nginx/your-domain.conf`
在站点的 `location /` 块中添加:
```nginx
location / {
# 设置最大上传文件大小为5GB
client_max_body_size 5G;
# 大文件上传超时设置1小时
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 3600s;
# ... 其他代理配置
}
```
然后重载Nginx
```bash
nginx -s reload
```
## 上传限制说明
系统支持的最大上传文件大小为 **5GB**,需要在以下三个层级进行配置:
### 1. ✅ 容器内Nginx已配置
- 文件:`nginx/nginx.conf`
- 配置:`client_max_body_size 5G;`
### 2. ✅ 后端Multer已配置
- 文件:`backend/server.js`
- 配置:`limits: { fileSize: 5 * 1024 * 1024 * 1024 }`
### 3. ⚠️ 宿主机Nginx需要手动配置
- 如果使用宿主机Nginx作为反向代理
- 必须在 `location /` 块中添加 `client_max_body_size 5G;`
- 否则上传会在64MB时失败Nginx默认限制
## 故障排查
### 上传文件提示413错误
**问题**上传大于64MB的文件时失败浏览器控制台显示 `413 Payload Too Large`
**原因**宿主机Nginx的 `client_max_body_size` 限制默认1MB或64MB
**解决方案**
1. 找到宿主机Nginx配置文件通常是 `/etc/nginx/sites-available/``/www/server/panel/vhost/nginx/`
2.`location /` 块中添加:
```nginx
client_max_body_size 5G;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 3600s;
```
3. 测试并重载Nginx
```bash
nginx -t
nginx -s reload
```
### 上传进度
前端已实现实时上传进度显示使用axios的 `onUploadProgress`),无需额外配置。
## 存储配置
系统支持两种存储方式:
### 本地存储
- 文件存储在:`backend/local-storage/`
- 可设置用户配额限制
- 适合中小型部署
### SFTP存储
- 用户可配置自己的SFTP服务器
- 支持HTTP直接下载配置 `http_download_base_url`
- 适合大规模部署
## 安全建议
1. **使用HTTPS**生产环境务必配置SSL证书
2. **定期备份数据库**`backend/data.db` 包含所有用户数据
3. **限制管理员账号**:定期审查用户权限
4. **配置防火墙**只开放必要的端口80, 443
## 技术支持
如有问题,请查看日志:
```bash
# 后端日志
docker logs wanwanyun-backend
# 前端日志
docker logs wanwanyun-frontend
# Nginx日志
tail -f /www/wwwlogs/your-domain.log
tail -f /www/wwwlogs/your-domain.error.log
```

1211
DOCKER部署指南.md Normal file

File diff suppressed because it is too large Load Diff

49
QUICK_START.txt Normal file
View File

@@ -0,0 +1,49 @@
╔══════════════════════════════════════════════════════════════╗
║ 玩玩云 - 快速开始指南 ║
╚══════════════════════════════════════════════════════════════╝
【最简单的部署方法 - 3步完成】
1⃣ 上传项目到服务器的 /var/www/ 目录
2⃣ SSH登录服务器执行
cd /var/www/ftp-web-manager
bash deploy.sh
3⃣ 打开浏览器访问:
http://服务器IP:8080
默认账号admin
默认密码admin123
✅ 部署完成!
───────────────────────────────────────────────────────────────
【环境要求】
✓ Docker 20.10+
✓ Docker Compose 2.0+
✓ Linux系统
✓ 1GB+ 内存
───────────────────────────────────────────────────────────────
【详细文档】
📖 完整部署指南DEPLOY.md
📖 使用说明README.md
📖 部署检查报告:桌面上的检查报告
───────────────────────────────────────────────────────────────
【重要提示】
⚠️ 首次登录后立即修改admin密码
⚠️ 生产环境请配置HTTPS
⚠️ 定期备份 backend/ftp-manager.db 数据库文件
───────────────────────────────────────────────────────────────
【获取帮助】
💬 查看常见问题DEPLOY.md
💬 查看详细文档README.md
祝您使用愉快!☁️

244
README.md Normal file
View File

@@ -0,0 +1,244 @@
# 玩玩云 - Web SFTP 文件管理系统
> 一个基于Web的SFTP文件管理系统提供文件上传、下载、分享等功能支持多用户管理。
## 📋 项目简介
玩玩云是一个现代化的Web文件管理系统,让您可以通过浏览器管理SFTP服务器上的文件。系统支持文件的上传、下载、重命名、删除、分享等操作并提供桌面端上传工具方便快速上传大文件。
### 主要特性
-**文件管理** - 浏览、上传、下载、重命名、删除文件
-**文件分享** - 生成分享链接,支持密码保护和有效期设置
-**多用户系统** - 用户注册、登录、权限管理
-**桌面上传工具** - 拖拽上传,实时显示进度
-**流式下载** - 服务器零存储,纯中转下载
-**管理员功能** - 用户管理、文件审查、系统设置
-**Docker部署** - 一键部署,易于维护
## 🛠️ 技术栈
### 前端
- **Vue.js 3** - 渐进式JavaScript框架
- **Axios** - HTTP请求库
- **Font Awesome** - 图标库
### 后端
- **Node.js 20** - JavaScript运行时
- **Express** - Web应用框架
- **better-sqlite3** - 轻量级数据库
- **ssh2-sftp-client** - SFTP客户端
- **JWT** - 用户认证
### 部署
- **Docker** - 容器化
- **Docker Compose** - 容器编排
- **Nginx** - 反向代理
## 🚀 快速开始
### 环境要求
- **Docker**: 20.10.0+
- **Docker Compose**: 2.0.0+
- **操作系统**: Linux (Ubuntu 20.04+ / Debian 10+ / CentOS 7+)
- **内存**: 最低 1GB RAM推荐 2GB+
### 方法1: 一键部署(推荐)
```bash
# 1. 上传或克隆项目
cd /var/www
# 将项目文件上传到此目录
# 2. 进入项目目录
cd ftp-web-manager
# 3. 一键部署
bash deploy.sh
```
deploy.sh脚本会自动
- 检查Docker和Docker Compose环境
- 创建必要的目录
- 构建并启动所有服务
- 显示访问信息和默认账号
### 方法2: 手动部署
```bash
# 1. 进入项目目录
cd /var/www/ftp-web-manager
# 2. 创建必要的目录
mkdir -p certbot/conf certbot/www backend/uploads
# 3. 构建并启动服务
docker-compose up --build -d
# 4. 查看日志
docker-compose logs -f
```
### 访问系统
部署完成后:
- **前端地址**: http://服务器IP:8080
- **后端API**: http://服务器IP:40001
- **默认管理员账号**:
- 用户名: `admin`
- 密码: `admin123`
- ⚠️ **请立即登录并修改密码!**
## 📖 使用教程
### 配置SFTP服务器
首次使用需要配置SFTP连接信息
1. 登录后点击右上角用户菜单
2. 选择"设置"
3. 填写SFTP配置
- **SFTP主机**: 您的SFTP服务器IP
- **SFTP端口**: 默认22
- **SFTP用户名**: SFTP账号
- **SFTP密码**: SFTP密码
- **HTTP下载基础URL**(可选): 如果有HTTP直接下载地址
4. 点击"保存配置"
### 文件管理
- **浏览文件**: 点击文件夹图标进入子目录
- **上传文件**: 点击"上传文件"按钮选择本地文件
- **下载文件**: 点击文件行的下载按钮
- **重命名**: 点击"重命名"按钮修改文件名
- **删除**: 点击"删除"按钮删除文件
### 文件分享
1. 点击文件行的"分享"按钮
2. 设置分享选项:
- **分享密码**(可选)
- **有效期**(可选)
3. 复制分享链接发送给他人
## 🔧 维护操作
### 查看日志
```bash
# 查看所有日志
docker-compose logs -f
# 查看后端日志
docker-compose logs -f backend
```
### 重启服务
```bash
# 重启所有容器
docker-compose restart
# 重启指定容器
docker-compose restart backend
```
### 停止服务
```bash
docker-compose down
```
### 备份数据
```bash
# 备份数据库
cp backend/ftp-manager.db backup/ftp-manager.db.$(date +%Y%m%d)
# 备份整个项目
tar -czf backup/wanwanyun-$(date +%Y%m%d).tar.gz .
```
### 更新代码
```bash
# 拉取最新代码
git pull
# 重新构建并重启
docker-compose up --build -d
```
## 🔐 安全建议
1. **修改默认密码**: 首次登录后立即修改admin密码
2. **使用HTTPS**: 配置SSL证书使用HTTPS访问
3. **修改JWT密钥**: 在backend/.env文件中设置随机的JWT_SECRET
4. **定期备份**: 定期备份数据库文件
5. **限制端口**: 不要对外暴露40001端口只通过Nginx访问
## ❓ 常见问题
### Docker容器启动失败
```bash
# 查看日志
docker-compose logs backend
# 重新构建
docker-compose down
docker-compose up --build -d
```
### 上传失败提示权限错误
检查SFTP服务器目录权限确保上传目录有写权限。
### 分享链接无法访问
检查nginx配置和防火墙设置确保端口8080可访问。
## 📁 项目结构
```
ftp-web-manager/
├── backend/ # 后端代码
│ ├── server.js # 主服务器文件
│ ├── database.js # 数据库操作
│ ├── auth.js # 认证中间件
│ ├── Dockerfile # Docker镜像
│ └── package.json # 依赖配置
├── frontend/ # 前端代码
│ ├── index.html # 登录页面
│ ├── app.html # 主应用页面
│ ├── share.html # 分享页面
│ └── libs/ # 第三方库
├── nginx/ # Nginx配置
│ └── nginx.conf # 配置文件
├── upload-tool/ # 上传工具
│ ├── upload_tool.py # Python源码
│ └── build.bat # 打包脚本
├── docker-compose.yml # Docker编排
├── deploy.sh # 一键部署脚本
├── .gitignore # Git忽略文件
└── README.md # 本文件
```
## 🤝 贡献指南
欢迎提交Issue和Pull Request
## 📄 许可证
本项目仅供学习和个人使用。
---
**玩玩云** - 让文件管理更简单 ☁️

98
VERSION.txt Normal file
View File

@@ -0,0 +1,98 @@
玩玩云 - 版本信息
═══════════════════════════════════════
版本号: v1.0.0
发布日期: 2025-11-09
状态: 生产就绪 ✅
═══════════════════════════════════════
【本版本特性】
✅ 完整的文件管理功能
- SFTP文件浏览、上传、下载
- 文件重命名、删除
- 流式下载,支持进度显示
✅ 文件分享功能
- 生成分享链接
- 支持密码保护
- 支持有效期设置
- 双模式下载HTTP/SFTP
✅ 用户管理系统
- 用户注册、登录
- 密码加密存储
- JWT认证
- 管理员权限管理
✅ 桌面上传工具
- 拖拽上传
- 实时进度显示
- 自动配置
✅ Docker容器化部署
- 一键部署脚本
- 自动环境检查
- 完整的日志记录
═══════════════════════════════════════
【技术栈】
后端:
- Node.js 20
- Express 4.x
- better-sqlite3
- ssh2-sftp-client
- JWT认证
前端:
- Vue.js 3
- Axios
- Font Awesome
部署:
- Docker
- Docker Compose
- Nginx
═══════════════════════════════════════
【已修复的问题】
✅ 数据库初始化语法错误
✅ 分享链接重定向错误
✅ 分享页面下载按钮缺失
✅ 密码验证错误
✅ SFTP连接过早关闭
✅ Docker配置不完整
═══════════════════════════════════════
【部署状态】
✅ 数据库自动初始化
✅ 默认管理员自动创建
✅ 数据库迁移逻辑完整
✅ Docker镜像自动构建
✅ 所有依赖配置齐全
✅ 部署脚本完整可用
═══════════════════════════════════════
【安全特性】
✅ 密码bcrypt加密
✅ JWT令牌认证
✅ SFTP密码安全存储
✅ SQL注入防护
✅ XSS防护
✅ CORS配置
═══════════════════════════════════════
更新日志: 查看 CHANGELOG.md (如有)
许可证: 仅供学习和个人使用
═══════════════════════════════════════

8
backend/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# FTP服务器配置
FTP_HOST=your-ftp-host.com
FTP_PORT=21
FTP_USER=your-username
FTP_PASSWORD=your-password
# 服务器配置
PORT=3000

21
backend/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:20-alpine
WORKDIR /app
# 安装编译工具
RUN apk add --no-cache python3 make g++
# 复制package文件
COPY package*.json ./
# 安装依赖
RUN npm install --production
# 复制应用代码
COPY . .
# 暴露端口
EXPOSE 40001
# 启动应用
CMD ["node", "server.js"]

108
backend/auth.js Normal file
View File

@@ -0,0 +1,108 @@
const jwt = require('jsonwebtoken');
const { UserDB } = require('./database');
// JWT密钥生产环境应该放在环境变量中
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
// 生成JWT Token
function generateToken(user) {
return jwt.sign(
{
id: user.id,
username: user.username,
is_admin: user.is_admin
},
JWT_SECRET,
{ expiresIn: '7d' }
);
}
// 验证Token中间件
function authMiddleware(req, res, next) {
// 从请求头、cookie或URL参数中获取token
const token = req.headers.authorization?.replace('Bearer ', '') || req.cookies?.token || req.query?.token;
if (!token) {
return res.status(401).json({
success: false,
message: '未提供认证令牌'
});
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const user = UserDB.findById(decoded.id);
if (!user) {
return res.status(401).json({
success: false,
message: '用户不存在'
});
}
if (user.is_banned) {
return res.status(403).json({
success: false,
message: '账号已被封禁'
});
}
if (!user.is_active) {
return res.status(403).json({
success: false,
message: '账号未激活'
});
}
// 将用户信息附加到请求对象(包含所有存储相关字段)
req.user = {
id: user.id,
username: user.username,
email: user.email,
is_admin: user.is_admin,
has_ftp_config: user.has_ftp_config,
ftp_host: user.ftp_host,
ftp_port: user.ftp_port,
ftp_user: user.ftp_user,
ftp_password: user.ftp_password,
http_download_base_url: user.http_download_base_url,
// 存储相关字段v2.0新增)
storage_permission: user.storage_permission || 'sftp_only',
current_storage_type: user.current_storage_type || 'sftp',
local_storage_quota: user.local_storage_quota || 1073741824,
local_storage_used: user.local_storage_used || 0
};
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
message: '令牌已过期'
});
}
return res.status(401).json({
success: false,
message: '无效的令牌'
});
}
}
// 管理员权限中间件
function adminMiddleware(req, res, next) {
if (!req.user || !req.user.is_admin) {
return res.status(403).json({
success: false,
message: '需要管理员权限'
});
}
next();
}
module.exports = {
JWT_SECRET,
generateToken,
authMiddleware,
adminMiddleware
};

52
backend/backup.bat Normal file
View File

@@ -0,0 +1,52 @@
@echo off
chcp 65001 >nul
echo ========================================
echo 数据库备份工具
echo ========================================
echo.
cd /d %~dp0
REM 创建备份目录
if not exist backup mkdir backup
REM 生成时间戳
set YEAR=%date:~0,4%
set MONTH=%date:~5,2%
set DAY=%date:~8,2%
set HOUR=%time:~0,2%
set MINUTE=%time:~3,2%
set SECOND=%time:~6,2%
REM 去掉小时前面的空格
if "%HOUR:~0,1%" == " " set HOUR=0%HOUR:~1,1%
set TIMESTAMP=%YEAR%%MONTH%%DAY%_%HOUR%%MINUTE%%SECOND%
REM 备份数据库
copy ftp-manager.db backup\ftp-manager-%TIMESTAMP%.db >nul
if %errorlevel% == 0 (
echo [成功] 备份完成!
echo 文件: backup\ftp-manager-%TIMESTAMP%.db
REM 获取文件大小
for %%A in (backup\ftp-manager-%TIMESTAMP%.db) do echo 大小: %%~zA 字节
) else (
echo [错误] 备份失败!
)
echo.
REM 清理30天前的备份
echo 清理30天前的旧备份...
forfiles /P backup /M ftp-manager-*.db /D -30 /C "cmd /c del @path" 2>nul
if %errorlevel% == 0 (
echo [成功] 旧备份已清理
) else (
echo [提示] 没有需要清理的旧备份
)
echo.
echo ========================================
pause

554
backend/database.js Normal file
View File

@@ -0,0 +1,554 @@
const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const path = require('path');
// 创建或连接数据库
const db = new Database(path.join(__dirname, 'ftp-manager.db'));
// 启用外键约束
db.pragma('foreign_keys = ON');
// 初始化数据库表
function initDatabase() {
// 用户表
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
-- FTP配置可选
ftp_host TEXT,
ftp_port INTEGER DEFAULT 22,
ftp_user TEXT,
ftp_password TEXT,
http_download_base_url TEXT,
-- 上传工具API密钥
upload_api_key TEXT,
-- 用户状态
is_admin INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
is_banned INTEGER DEFAULT 0,
has_ftp_config INTEGER DEFAULT 0,
-- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// 分享链接表
db.exec(`
CREATE TABLE IF NOT EXISTS shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
share_code TEXT UNIQUE NOT NULL,
share_path TEXT NOT NULL,
share_type TEXT DEFAULT 'file',
share_password TEXT,
-- 分享统计
view_count INTEGER DEFAULT 0,
download_count INTEGER DEFAULT 0,
-- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
// 系统设置表
db.exec(`
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// 密码重置请求表
db.exec(`
CREATE TABLE IF NOT EXISTS password_reset_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
new_password TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- pending, approved, rejected
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
reviewed_at DATETIME,
reviewed_by INTEGER,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (reviewed_by) REFERENCES users (id)
)
`);
// 创建索引
db.exec(`
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_shares_code ON shares(share_code);
CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id);
CREATE INDEX IF NOT EXISTS idx_reset_requests_user ON password_reset_requests(user_id);
CREATE INDEX IF NOT EXISTS idx_reset_requests_status ON password_reset_requests(status);
`);
// 数据库迁移添加upload_api_key字段如果不存在
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasUploadApiKey = columns.some(col => col.name === 'upload_api_key');
if (!hasUploadApiKey) {
db.exec(`ALTER TABLE users ADD COLUMN upload_api_key TEXT`);
console.log('数据库迁移添加upload_api_key字段完成');
}
} catch (error) {
console.error('数据库迁移失败:', error);
}
// 数据库迁移添加share_type字段如果不存在
try {
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
const hasShareType = shareColumns.some(col => col.name === 'share_type');
if (!hasShareType) {
db.exec(`ALTER TABLE shares ADD COLUMN share_type TEXT DEFAULT 'file'`);
console.log('数据库迁移添加share_type字段完成');
}
} catch (error) {
console.error('数据库迁移share_type失败:', error);
}
console.log('数据库初始化完成');
}
// 创建默认管理员账号
function createDefaultAdmin() {
const adminExists = db.prepare('SELECT id FROM users WHERE is_admin = 1').get();
if (!adminExists) {
const hashedPassword = bcrypt.hashSync('admin123', 10);
db.prepare(`
INSERT INTO users (
username, email, password,
is_admin, is_active, has_ftp_config
) VALUES (?, ?, ?, ?, ?, ?)
`).run(
'admin',
'admin@example.com',
hashedPassword,
1,
1,
0 // 管理员不需要FTP配置
);
console.log('默认管理员账号已创建');
console.log('用户名: admin');
console.log('密码: admin123');
console.log('⚠️ 请登录后立即修改密码!');
}
}
// 用户相关操作
const UserDB = {
// 创建用户
create(userData) {
const hashedPassword = bcrypt.hashSync(userData.password, 10);
const hasFtpConfig = userData.ftp_host && userData.ftp_user && userData.ftp_password ? 1 : 0;
const stmt = db.prepare(`
INSERT INTO users (
username, email, password,
ftp_host, ftp_port, ftp_user, ftp_password, http_download_base_url,
has_ftp_config
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
userData.username,
userData.email,
hashedPassword,
userData.ftp_host || null,
userData.ftp_port || 22,
userData.ftp_user || null,
userData.ftp_password || null,
userData.http_download_base_url || null,
hasFtpConfig
);
return result.lastInsertRowid;
},
// 根据用户名查找
findByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
},
// 根据邮箱查找
findByEmail(email) {
return db.prepare('SELECT * FROM users WHERE email = ?').get(email);
},
// 根据ID查找
findById(id) {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
},
// 验证密码
verifyPassword(plainPassword, hashedPassword) {
return bcrypt.compareSync(plainPassword, hashedPassword);
},
// 更新用户
update(id, updates) {
const fields = [];
const values = [];
for (const [key, value] of Object.entries(updates)) {
if (key === 'password') {
fields.push(`${key} = ?`);
values.push(bcrypt.hashSync(value, 10));
} else {
fields.push(`${key} = ?`);
values.push(value);
}
}
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
const stmt = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`);
return stmt.run(...values);
},
// 获取所有用户
getAll(filters = {}) {
let query = 'SELECT * FROM users WHERE 1=1';
const params = [];
if (filters.is_admin !== undefined) {
query += ' AND is_admin = ?';
params.push(filters.is_admin);
}
if (filters.is_banned !== undefined) {
query += ' AND is_banned = ?';
params.push(filters.is_banned);
}
query += ' ORDER BY created_at DESC';
return db.prepare(query).all(...params);
},
// 删除用户
delete(id) {
return db.prepare('DELETE FROM users WHERE id = ?').run(id);
},
// 封禁/解封用户
setBanStatus(id, isBanned) {
return db.prepare('UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(isBanned ? 1 : 0, id);
}
};
// 分享链接相关操作
const ShareDB = {
// 生成随机分享码
generateShareCode(length = 8) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let code = '';
for (let i = 0; i < length; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
},
// 创建分享链接
// 创建分享链接
create(userId, options = {}) {
const {
share_type = 'file',
file_path = '',
file_name = '',
password = null,
expiry_days = null
} = options;
let shareCode;
let attempts = 0;
// 尝试生成唯一的分享码
do {
shareCode = this.generateShareCode();
attempts++;
if (attempts > 10) {
shareCode = this.generateShareCode(10); // 增加长度
}
} while (this.findByCode(shareCode) && attempts < 20);
// 计算过期时间
let expiresAt = null;
if (expiry_days) {
const expireDate = new Date();
expireDate.setDate(expireDate.getDate() + parseInt(expiry_days));
expiresAt = expireDate.toISOString();
}
const stmt = db.prepare(`
INSERT INTO shares (user_id, share_code, share_path, share_type, share_password, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
const hashedPassword = password ? bcrypt.hashSync(password, 10) : null;
const sharePath = share_type === 'file' ? file_path : '/';
const result = stmt.run(
userId,
shareCode,
sharePath,
share_type,
hashedPassword,
expiresAt
);
return {
id: result.lastInsertRowid,
share_code: shareCode,
share_type: share_type
};
},
// 根据分享码查找
findByCode(shareCode) {
return db.prepare(`
SELECT s.*, u.username, u.ftp_host, u.ftp_port, u.ftp_user, u.ftp_password, u.http_download_base_url
FROM shares s
JOIN users u ON s.user_id = u.id
WHERE s.share_code = ?
`).get(shareCode);
},
// 根据ID查找
findById(id) {
return db.prepare('SELECT * FROM shares WHERE id = ?').get(id);
},
// 验证分享密码
verifyPassword(plainPassword, hashedPassword) {
return bcrypt.compareSync(plainPassword, hashedPassword);
},
// 获取用户的所有分享
getUserShares(userId) {
return db.prepare(`
SELECT * FROM shares
WHERE user_id = ?
ORDER BY created_at DESC
`).all(userId);
},
// 增加查看次数
incrementViewCount(shareCode) {
return db.prepare(`
UPDATE shares
SET view_count = view_count + 1
WHERE share_code = ?
`).run(shareCode);
},
// 增加下载次数
incrementDownloadCount(shareCode) {
return db.prepare(`
UPDATE shares
SET download_count = download_count + 1
WHERE share_code = ?
`).run(shareCode);
},
// 删除分享
delete(id, userId = null) {
if (userId) {
return db.prepare('DELETE FROM shares WHERE id = ? AND user_id = ?').run(id, userId);
}
return db.prepare('DELETE FROM shares WHERE id = ?').run(id);
},
// 获取所有分享(管理员)
getAll() {
return db.prepare(`
SELECT s.*, u.username
FROM shares s
JOIN users u ON s.user_id = u.id
ORDER BY s.created_at DESC
`).all();
}
};
// 系统设置管理
const SettingsDB = {
// 获取设置
get(key) {
const row = db.prepare('SELECT value FROM system_settings WHERE key = ?').get(key);
return row ? row.value : null;
},
// 设置值
set(key, value) {
db.prepare(`
INSERT INTO system_settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
`).run(key, value);
},
// 获取所有设置
getAll() {
return db.prepare('SELECT key, value FROM system_settings').all();
}
};
// 密码重置请求管理
const PasswordResetDB = {
// 创建密码重置请求
create(userId, newPassword) {
const hashedPassword = bcrypt.hashSync(newPassword, 10);
// 删除该用户之前的pending请求
db.prepare('DELETE FROM password_reset_requests WHERE user_id = ? AND status = ?')
.run(userId, 'pending');
const stmt = db.prepare(`
INSERT INTO password_reset_requests (user_id, new_password, status)
VALUES (?, ?, 'pending')
`);
const result = stmt.run(userId, hashedPassword);
return result.lastInsertRowid;
},
// 获取待审核的请求
getPending() {
return db.prepare(`
SELECT r.*, u.username, u.email
FROM password_reset_requests r
JOIN users u ON r.user_id = u.id
WHERE r.status = 'pending'
ORDER BY r.created_at DESC
`).all();
},
// 审核请求(批准或拒绝)
review(requestId, adminId, approved) {
const request = db.prepare('SELECT * FROM password_reset_requests WHERE id = ?').get(requestId);
if (!request || request.status !== 'pending') {
throw new Error('请求不存在或已被处理');
}
const newStatus = approved ? 'approved' : 'rejected';
db.prepare(`
UPDATE password_reset_requests
SET status = ?, reviewed_at = CURRENT_TIMESTAMP, reviewed_by = ?
WHERE id = ?
`).run(newStatus, adminId, requestId);
// 如果批准,更新用户密码
if (approved) {
db.prepare('UPDATE users SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(request.new_password, request.user_id);
}
return true;
},
// 获取用户的所有请求
getUserRequests(userId) {
return db.prepare(`
SELECT * FROM password_reset_requests
WHERE user_id = ?
ORDER BY created_at DESC
`).all(userId);
},
// 检查用户是否有待处理的请求
hasPendingRequest(userId) {
const request = db.prepare(`
SELECT id FROM password_reset_requests
WHERE user_id = ? AND status = 'pending'
`).get(userId);
return !!request;
}
};
// 初始化默认设置
function initDefaultSettings() {
// 默认上传限制为100MB
if (!SettingsDB.get('max_upload_size')) {
SettingsDB.set('max_upload_size', '104857600'); // 100MB in bytes
}
}
// 数据库版本迁移 - v2.0 本地存储功能
function migrateToV2() {
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasStoragePermission = columns.some(col => col.name === 'storage_permission');
if (!hasStoragePermission) {
console.log('[数据库迁移] 检测到旧版本数据库,开始升级到 v2.0...');
// 添加本地存储相关字段
db.exec(`
ALTER TABLE users ADD COLUMN storage_permission TEXT DEFAULT 'sftp_only';
ALTER TABLE users ADD COLUMN current_storage_type TEXT DEFAULT 'sftp';
ALTER TABLE users ADD COLUMN local_storage_quota INTEGER DEFAULT 1073741824;
ALTER TABLE users ADD COLUMN local_storage_used INTEGER DEFAULT 0;
`);
// 更新现有用户为SFTP模式保持兼容
const updateStmt = db.prepare("UPDATE users SET current_storage_type = 'sftp' WHERE has_ftp_config = 1");
updateStmt.run();
console.log('[数据库迁移] ✓ 用户表已升级');
// 为分享表添加存储类型字段
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
const hasShareStorageType = shareColumns.some(col => col.name === 'storage_type');
if (!hasShareStorageType) {
db.exec(`ALTER TABLE shares ADD COLUMN storage_type TEXT DEFAULT 'sftp';`);
console.log('[数据库迁移] ✓ 分享表已升级');
}
console.log('[数据库迁移] ✅ 数据库升级到 v2.0 完成!本地存储功能已启用');
}
} catch (error) {
console.error('[数据库迁移] 迁移失败:', error);
throw error;
}
}
// 初始化数据库
initDatabase();
createDefaultAdmin();
initDefaultSettings();
migrateToV2(); // 执行数据库迁移
module.exports = {
db,
UserDB,
ShareDB,
SettingsDB,
PasswordResetDB
};

3010
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
backend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "ftp-web-manager-backend",
"version": "1.0.0",
"description": "FTP Web Manager Backend",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [
"ftp",
"web",
"file-manager"
],
"author": "",
"license": "MIT",
"dependencies": {
"archiver": "^7.0.1",
"basic-ftp": "^5.0.4",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-validator": "^7.3.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"ssh2-sftp-client": "^12.0.1"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

2113
backend/server.js Normal file

File diff suppressed because it is too large Load Diff

10
backend/start.bat Normal file
View File

@@ -0,0 +1,10 @@
@echo off
echo ========================================
echo FTP 网盘管理平台 - 启动脚本
echo ========================================
echo.
cd /d %~dp0
node server.js
pause

321
backend/storage.js Normal file
View File

@@ -0,0 +1,321 @@
const SftpClient = require('ssh2-sftp-client');
const fs = require('fs');
const path = require('path');
const { UserDB } = require('./database');
// ===== 统一存储接口 =====
/**
* 存储接口工厂
* 根据用户的存储类型返回对应的存储客户端
*/
class StorageInterface {
constructor(user) {
this.user = user;
this.type = user.current_storage_type || 'sftp';
}
/**
* 创建并返回存储客户端
*/
async connect() {
if (this.type === 'local') {
const client = new LocalStorageClient(this.user);
await client.init();
return client;
} else {
const client = new SftpStorageClient(this.user);
await client.connect();
return client;
}
}
}
// ===== 本地存储客户端 =====
class LocalStorageClient {
constructor(user) {
this.user = user;
// 使用环境变量或默认路径(不硬编码)
const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage');
this.basePath = path.join(storageRoot, `user_${user.id}`);
}
/**
* 初始化用户存储目录
*/
async init() {
if (!fs.existsSync(this.basePath)) {
fs.mkdirSync(this.basePath, { recursive: true, mode: 0o755 });
console.log(`[本地存储] 创建用户目录: ${this.basePath}`);
}
}
/**
* 列出目录内容
*/
async list(dirPath) {
const fullPath = this.getFullPath(dirPath);
// 确保目录存在
if (!fs.existsSync(fullPath)) {
fs.mkdirSync(fullPath, { recursive: true });
return [];
}
const items = fs.readdirSync(fullPath, { withFileTypes: true });
return items.map(item => {
const itemPath = path.join(fullPath, item.name);
const stats = fs.statSync(itemPath);
return {
name: item.name,
type: item.isDirectory() ? 'd' : '-',
size: stats.size,
modifyTime: stats.mtimeMs
};
});
}
/**
* 上传文件
*/
async put(localPath, remotePath) {
const destPath = this.getFullPath(remotePath);
// 检查配额
const fileSize = fs.statSync(localPath).size;
this.checkQuota(fileSize);
// 确保目标目录存在
const destDir = path.dirname(destPath);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
// 使用临时文件+重命名模式,避免文件被占用问题
const tempPath = `${destPath}.uploading_${Date.now()}`;
try {
// 复制到临时文件
fs.copyFileSync(localPath, tempPath);
// 如果目标文件存在,先删除
if (fs.existsSync(destPath)) {
fs.unlinkSync(destPath);
}
// 重命名临时文件为目标文件
fs.renameSync(tempPath, destPath);
// 更新已使用空间
this.updateUsedSpace(fileSize);
} catch (error) {
// 清理临时文件
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
throw error;
}
}
/**
* 删除文件
*/
async delete(filePath) {
const fullPath = this.getFullPath(filePath);
const stats = fs.statSync(fullPath);
fs.unlinkSync(fullPath);
// 更新已使用空间
this.updateUsedSpace(-stats.size);
}
/**
* 重命名文件
*/
async rename(oldPath, newPath) {
const oldFullPath = this.getFullPath(oldPath);
const newFullPath = this.getFullPath(newPath);
// 确保新路径的目录存在
const newDir = path.dirname(newFullPath);
if (!fs.existsSync(newDir)) {
fs.mkdirSync(newDir, { recursive: true });
}
fs.renameSync(oldFullPath, newFullPath);
}
/**
* 获取文件信息
*/
async stat(filePath) {
const fullPath = this.getFullPath(filePath);
return fs.statSync(fullPath);
}
/**
* 创建文件读取流
*/
createReadStream(filePath) {
const fullPath = this.getFullPath(filePath);
return fs.createReadStream(fullPath);
}
/**
* 关闭连接(本地存储无需关闭)
*/
async end() {
// 本地存储无需关闭连接
}
// ===== 辅助方法 =====
/**
* 获取完整路径(带安全检查)
*/
getFullPath(relativePath) {
// 1. 规范化路径,移除 ../ 等危险路径
const normalized = path.normalize(relativePath).replace(/^(\.\.[\/\\])+/, '');
// 2. 拼接完整路径
const fullPath = path.join(this.basePath, normalized);
// 3. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
if (!fullPath.startsWith(this.basePath)) {
throw new Error('非法路径访问');
}
return fullPath;
}
/**
* 检查配额
*/
checkQuota(additionalSize) {
const newUsed = (this.user.local_storage_used || 0) + additionalSize;
if (newUsed > this.user.local_storage_quota) {
const used = this.formatSize(this.user.local_storage_used);
const quota = this.formatSize(this.user.local_storage_quota);
const need = this.formatSize(additionalSize);
throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`);
}
}
/**
* 更新已使用空间
*/
updateUsedSpace(delta) {
const newUsed = Math.max(0, (this.user.local_storage_used || 0) + delta);
UserDB.update(this.user.id, { local_storage_used: newUsed });
// 更新内存中的值
this.user.local_storage_used = newUsed;
}
/**
* 格式化文件大小
*/
formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
}
// ===== SFTP存储客户端 =====
class SftpStorageClient {
constructor(user) {
this.user = user;
this.sftp = new SftpClient();
}
/**
* 连接SFTP服务器
*/
async connect() {
await this.sftp.connect({
host: this.user.ftp_host,
port: this.user.ftp_port || 22,
username: this.user.ftp_user,
password: this.user.ftp_password
});
return this;
}
/**
* 列出目录内容
*/
async list(dirPath) {
return await this.sftp.list(dirPath);
}
/**
* 上传文件
*/
async put(localPath, remotePath) {
// 使用临时文件+重命名模式与upload_tool保持一致
const tempRemotePath = `${remotePath}.uploading_${Date.now()}`;
// 第一步:上传到临时文件
await this.sftp.put(localPath, tempRemotePath);
// 第二步:检查目标文件是否存在,如果存在先删除
try {
await this.sftp.stat(remotePath);
await this.sftp.delete(remotePath);
} catch (err) {
// 文件不存在,无需删除
}
// 第三步:重命名临时文件为目标文件
await this.sftp.rename(tempRemotePath, remotePath);
}
/**
* 删除文件
*/
async delete(filePath) {
return await this.sftp.delete(filePath);
}
/**
* 重命名文件
*/
async rename(oldPath, newPath) {
return await this.sftp.rename(oldPath, newPath);
}
/**
* 获取文件信息
*/
async stat(filePath) {
return await this.sftp.stat(filePath);
}
/**
* 创建文件读取流
*/
createReadStream(filePath) {
return this.sftp.createReadStream(filePath);
}
/**
* 关闭连接
*/
async end() {
if (this.sftp) {
await this.sftp.end();
}
}
}
module.exports = {
StorageInterface,
LocalStorageClient,
SftpStorageClient
};

110
deploy.sh Normal file
View File

@@ -0,0 +1,110 @@
#!/bin/bash
# 玩玩云一键部署脚本
# 使用方法: bash deploy.sh
set -e
echo "========================================="
echo " 玩玩云 - 一键部署脚本"
echo "========================================="
echo ""
# 检查Docker
if ! command -v docker &> /dev/null; then
echo "❌ 错误: Docker未安装"
echo "请先安装Docker: https://docs.docker.com/engine/install/"
exit 1
fi
# 检查Docker Compose
if ! command -v docker-compose &> /dev/null; then
echo "❌ 错误: Docker Compose未安装"
echo "请先安装Docker Compose: https://docs.docker.com/compose/install/"
exit 1
fi
echo "✓ Docker版本: $(docker --version)"
echo "✓ Docker Compose版本: $(docker-compose --version)"
echo ""
# 检查必要的目录
echo "📁 检查项目结构..."
REQUIRED_DIRS=("backend" "frontend" "nginx")
for dir in "${REQUIRED_DIRS[@]}"; do
if [ ! -d "$dir" ]; then
echo "❌ 错误: 缺少 $dir 目录"
exit 1
fi
done
echo "✓ 项目结构完整"
echo ""
# 创建必要的目录
echo "📂 创建必要的目录..."
mkdir -p certbot/conf
mkdir -p certbot/www
mkdir -p backend/uploads
echo "✓ 目录创建完成"
echo ""
# 检查.env文件
if [ ! -f "backend/.env" ]; then
echo "⚠️ 警告: backend/.env 文件不存在"
if [ -f "backend/.env.example" ]; then
echo "正在从.env.example创建.env文件..."
cp backend/.env.example backend/.env
echo "✓ 已创建.env文件请根据需要修改配置"
else
echo "⚠️ 建议创建.env文件配置JWT密钥等参数"
fi
echo ""
fi
# 停止旧容器
echo "🔄 停止旧容器..."
docker-compose down 2>/dev/null || true
echo "✓ 旧容器已停止"
echo ""
# 构建并启动
echo "🚀 构建并启动服务..."
docker-compose up --build -d
# 等待服务启动
echo ""
echo "⏳ 等待服务启动..."
sleep 5
# 检查容器状态
echo ""
echo "📊 检查容器状态..."
docker-compose ps
# 检查后端日志
echo ""
echo "📝 后端启动日志:"
docker-compose logs --tail=20 backend
# 显示访问信息
echo ""
echo "========================================="
echo " 🎉 部署完成!"
echo "========================================="
echo ""
echo "📍 访问地址:"
echo " 前端: http://localhost:8080"
echo " 后端API: http://localhost:40001"
echo ""
echo "👤 默认管理员账号:"
echo " 用户名: admin"
echo " 密码: admin123"
echo " ⚠️ 请立即登录并修改密码!"
echo ""
echo "📚 查看日志:"
echo " docker-compose logs -f"
echo ""
echo "🛑 停止服务:"
echo " docker-compose down"
echo ""
echo "========================================="

53
docker-compose.yml Normal file
View File

@@ -0,0 +1,53 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
image: wanwanyun-backend
container_name: wanwanyun-backend
restart: always
ports:
- "40001:40001"
volumes:
- ./backend:/app
- /app/node_modules
- ./upload-tool:/upload-tool
- ./storage:/app/storage # 本地存储卷
environment:
- NODE_ENV=production
- STORAGE_ROOT=/app/storage # 存储根目录(不硬编码)
networks:
- wanwanyun-network
frontend:
image: nginx:alpine
container_name: wanwanyun-frontend
restart: always
ports:
- "8080:80"
- "8443:443"
volumes:
- ./frontend:/usr/share/nginx/html
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
depends_on:
- backend
networks:
- wanwanyun-network
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
certbot:
image: certbot/certbot
container_name: wanwanyun-certbot
restart: unless-stopped
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks:
wanwanyun-network:
driver: bridge

1997
frontend/app.html Normal file

File diff suppressed because it is too large Load Diff

1708
frontend/app.js Normal file

File diff suppressed because it is too large Load Diff

191
frontend/index.html Normal file
View File

@@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>玩玩云 - 主页</title>
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.navbar {
background: rgba(255, 255, 255, 0.95);
padding: 20px 50px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 20px rgba(0,0,0,0.1);
}
.logo {
font-size: 28px;
font-weight: 700;
color: #667eea;
display: flex;
align-items: center;
gap: 12px;
}
.nav-buttons { display: flex; gap: 15px; }
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 15px;
font-weight: 500;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
}
.btn-outline {
background: transparent;
color: #667eea;
border: 2px solid #667eea;
}
.btn-outline:hover {
background: #667eea;
color: white;
}
.hero {
max-width: 1200px;
margin: 80px auto;
padding: 0 50px;
text-align: center;
color: white;
}
.hero h1 {
font-size: 56px;
margin-bottom: 20px;
}
.hero p {
font-size: 20px;
margin-bottom: 40px;
opacity: 0.95;
}
.hero-buttons {
display: flex;
gap: 20px;
justify-content: center;
}
.features {
max-width: 1200px;
margin: 80px auto;
padding: 0 50px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 30px;
}
.feature-card {
background: white;
padding: 40px 30px;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
transition: all 0.3s;
}
.feature-card:hover {
transform: translateY(-8px);
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
}
.feature-icon {
font-size: 48px;
color: #667eea;
margin-bottom: 20px;
}
.feature-title {
font-size: 22px;
font-weight: 600;
margin-bottom: 12px;
color: #333;
}
.feature-desc {
font-size: 15px;
color: #666;
line-height: 1.6;
}
.footer {
text-align: center;
padding: 40px 20px;
color: white;
opacity: 0.9;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="logo">
<i class="fas fa-cloud"></i>
玩玩云
</div>
<div class="nav-buttons">
<a href="app.html?action=login" class="btn btn-outline">
<i class="fas fa-right-to-bracket"></i> 登录
</a>
<a href="app.html?action=register" class="btn btn-primary">
<i class="fas fa-user-plus"></i> 注册
</a>
</div>
</nav>
<div class="hero">
<h1><i class="fas fa-cloud"></i> 玩玩云管理平台</h1>
<p>简单、安全、高效的文件管理解决方案<br>连接你的SFTP服务器随时随地管理文件</p>
<div class="hero-buttons">
<a href="app.html?action=register" class="btn btn-primary" style="padding: 16px 32px; font-size: 18px;">
<i class="fas fa-rocket"></i> 立即开始
</a>
<a href="app.html?action=login" class="btn btn-outline" style="padding: 16px 32px; font-size: 18px;">
<i class="fas fa-right-to-bracket"></i> 已有账号登录
</a>
</div>
</div>
<div class="features">
<div class="feature-card">
<div class="feature-icon"><i class="fas fa-server"></i></div>
<div class="feature-title">连接你的SFTP</div>
<div class="feature-desc">支持连接任何SFTP服务器数据存储在你自己的服务器上更安全可靠</div>
</div>
<div class="feature-card">
<div class="feature-icon"><i class="fas fa-cloud-upload-alt"></i></div>
<div class="feature-title">轻松上传下载</div>
<div class="feature-desc">网盘式界面,拖拽上传,快速下载,文件管理从未如此简单</div>
</div>
<div class="feature-card">
<div class="feature-icon"><i class="fas fa-share-alt"></i></div>
<div class="feature-title">一键分享</div>
<div class="feature-desc">生成分享链接,可设置密码保护,轻松分享文件给朋友</div>
</div>
<div class="feature-card">
<div class="feature-icon"><i class="fas fa-lock"></i></div>
<div class="feature-title">安全可靠</div>
<div class="feature-desc">JWT认证密码加密存储保护你的数据安全</div>
</div>
<div class="feature-card">
<div class="feature-icon"><i class="fas fa-mobile-alt"></i></div>
<div class="feature-title">响应式设计</div>
<div class="feature-desc">完美支持桌面和移动设备,随时随地访问你的文件</div>
</div>
<div class="feature-card">
<div class="feature-icon"><i class="fas fa-chart-line"></i></div>
<div class="feature-title">分享统计</div>
<div class="feature-desc">查看分享链接的访问次数和下载统计,了解分享效果</div>
</div>
</div>
<div class="footer">
<p><i class="fas fa-heart" style="color: #ff6b6b;"></i> 玩玩云 © 2025</p>
<p style="margin-top: 10px; font-size: 14px;">轻松管理你的文件,让分享更简单</p>
</div>
</body>
</html>

3
frontend/libs/axios.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

18323
frontend/libs/vue.global.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

767
frontend/share.html Normal file
View File

@@ -0,0 +1,767 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文件分享 - 玩玩云</title>
<script src="libs/vue.global.prod.js"></script>
<script src="libs/axios.min.js"></script>
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 30px;
margin-bottom: 20px;
}
.title {
font-size: 24px;
font-weight: 700;
color: #667eea;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.form-group { margin-bottom: 20px; }
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #555;
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover { background: #5568d3; }
.alert { padding: 12px; border-radius: 8px; margin-bottom: 15px; }
.alert-error { background: #f8d7da; color: #721c24; }
.file-list {
list-style: none;
}
.file-item {
padding: 15px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
}
.file-item:hover {
background: #f5f5f5;
}
.file-info {
display: flex;
align-items: center;
gap: 15px;
}
.file-icon {
font-size: 24px;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
}
/* 视图切换按钮 */
.view-controls {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-bottom: 20px;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
/* 大图标视图 */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 25px;
padding: 10px 0;
}
.file-grid-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 15px;
border: 2px solid #e8e8e8;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
background: #fff;
}
.file-grid-item:hover {
background: #f8f9fa;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-color: #667eea;
transform: translateY(-3px);
}
.file-grid-icon {
font-size: 56px;
margin-bottom: 12px;
}
.file-grid-name {
font-size: 14px;
font-weight: 500;
text-align: center;
word-break: break-all;
margin-bottom: 8px;
max-width: 100%;
color: #333;
/* 固定显示2行超出显示省略号 */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
min-height: 39px;
max-height: 39px;
}
.file-grid-size {
font-size: 12px;
color: #999;
margin-bottom: 12px;
}
/* 单文件居中显示 */
.single-file-container {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 20px;
min-height: 300px;
}
.single-file-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 50px 40px;
border: 2px solid #e0e0e0;
border-radius: 16px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
max-width: 500px;
width: 100%;
transition: all 0.3s;
}
.single-file-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 32px rgba(0,0,0,0.18);
}
.single-file-icon {
font-size: 120px;
margin-bottom: 25px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.single-file-name {
font-size: 20px;
font-weight: 600;
text-align: center;
word-break: break-all;
margin-bottom: 15px;
color: #333;
}
.single-file-size {
font-size: 16px;
color: #666;
margin-bottom: 30px;
}
.single-file-download {
padding: 15px 40px;
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 30px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.single-file-download:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
/* 响应式设计 */
@media (max-width: 768px) {
.single-file-card {
padding: 30px 20px;
}
.single-file-icon {
font-size: 80px;
}
.single-file-name {
font-size: 16px;
}
.single-file-size {
font-size: 14px;
}
.single-file-download {
padding: 12px 30px;
font-size: 14px;
}
}
/* 分享不存在提示 */
.share-not-found {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.share-not-found-icon {
font-size: 100px;
color: #ccc;
margin-bottom: 30px;
animation: fadeIn 0.5s;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
.share-not-found-title {
font-size: 24px;
font-weight: 600;
color: #666;
margin-bottom: 15px;
}
.share-not-found-message {
font-size: 16px;
color: #999;
line-height: 1.6;
}
/* 移动端适配 */
@media (max-width: 768px) {
body {
padding: 10px;
}
.container {
max-width: 100%;
}
.card {
padding: 20px;
border-radius: 8px;
}
.title {
font-size: 20px;
margin-bottom: 15px;
}
/* 视图切换按钮移动端优化 */
.view-controls {
margin-bottom: 15px;
}
.view-controls .btn {
padding: 8px 14px;
font-size: 13px;
}
/* 表单移动端优化 */
.form-group {
margin-bottom: 15px;
}
.form-label {
font-size: 14px;
margin-bottom: 6px;
}
.form-input {
padding: 10px 12px;
font-size: 14px;
}
.btn {
padding: 8px 16px;
font-size: 14px;
}
/* 文件网格视图移动端优化 */
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 15px;
}
.file-grid-item {
padding: 15px 10px;
}
.file-grid-icon {
font-size: 44px !important;
}
.file-grid-name {
font-size: 12px;
min-height: 34px;
max-height: 34px;
margin-bottom: 6px;
}
.file-grid-size {
font-size: 11px;
margin-bottom: 10px;
}
.file-grid-item .btn {
padding: 6px 10px;
font-size: 12px;
}
/* 列表视图移动端优化 */
.file-list {
padding: 0;
}
.file-item {
padding: 12px 8px;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.file-info {
gap: 10px;
width: 100%;
}
.file-icon {
font-size: 20px !important;
}
.file-item .btn {
width: 100%;
padding: 8px 12px;
}
/* 单文件显示移动端优化 */
.single-file-container {
padding: 20px 10px;
min-height: 250px;
}
.single-file-card {
padding: 30px 20px;
max-width: 100%;
}
.single-file-icon {
font-size: 80px !important;
margin-bottom: 20px;
}
.single-file-name {
font-size: 16px;
margin-bottom: 12px;
}
.single-file-size {
font-size: 14px;
margin-bottom: 20px;
}
.single-file-download {
padding: 12px 30px;
font-size: 14px;
}
/* 分享不存在提示移动端优化 */
.share-not-found {
padding: 40px 15px;
}
.share-not-found-icon {
font-size: 70px;
margin-bottom: 20px;
}
.share-not-found-title {
font-size: 20px;
margin-bottom: 12px;
}
.share-not-found-message {
font-size: 14px;
}
/* 加载状态移动端优化 */
.loading {
padding: 30px 15px;
}
}
/* 超小屏幕优化 (手机竖屏) */
@media (max-width: 480px) {
.card {
padding: 15px;
}
.title {
font-size: 18px;
}
/* 文件网格更紧凑 */
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(75px, 1fr));
gap: 10px;
}
.file-grid-icon {
font-size: 36px !important;
}
.file-grid-name {
font-size: 11px;
min-height: 31px;
max-height: 31px;
}
.file-grid-size {
font-size: 10px;
}
/* 单文件显示更紧凑 */
.single-file-icon {
font-size: 60px !important;
}
.single-file-name {
font-size: 14px;
}
.single-file-size {
font-size: 13px;
}
.single-file-download {
padding: 10px 24px;
font-size: 13px;
}
/* 视图切换按钮 */
.view-controls .btn {
padding: 6px 10px;
font-size: 12px;
}
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<div class="card">
<div class="title">
<i class="fas fa-cloud"></i>
文件分享
</div>
<!-- 通用错误显示 -->
<div v-if="errorMessage && !needPassword && !verified && !shareNotFound" class="share-not-found">
<i class="fas fa-exclamation-circle share-not-found-icon" style="color: #dc3545;"></i>
<div class="share-not-found-title">访问失败</div>
<div class="share-not-found-message">{{ errorMessage }}</div>
</div>
<!-- 密码验证 -->
<div v-if="needPassword && !verified && !shareNotFound">
<div v-if="errorMessage" class="alert alert-error">{{ errorMessage }}</div>
<div class="form-group">
<label class="form-label">请输入分享密码</label>
<input type="password" class="form-input" v-model="password" @keyup.enter="verifyShare" placeholder="输入密码">
</div>
<button class="btn btn-primary" @click="verifyShare">
<i class="fas fa-unlock"></i> 验证
</button>
</div>
<!-- 分享不存在 -->
<div v-else-if="shareNotFound" class="share-not-found">
<i class="fas fa-inbox share-not-found-icon"></i>
<div class="share-not-found-title">来晚了~</div>
<div class="share-not-found-message">
分享的内容已经取消了<br>
或者该分享链接已过期
</div>
</div>
<!-- 文件列表 -->
<div v-else-if="verified">
<p style="color: #666; margin-bottom: 20px;">
分享者: <strong>{{ shareInfo.username }}</strong> |
创建时间: {{ formatDate(shareInfo.created_at) }}
</p>
<!-- 视图切换按钮 (多文件时才显示) -->
<div v-if="files.length > 1" class="view-controls">
<button class="btn" :class="viewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="viewMode = 'grid'">
<i class="fas fa-th-large"></i> 大图标
</button>
<button class="btn" :class="viewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="viewMode = 'list'">
<i class="fas fa-list"></i> 列表
</button>
</div>
<div v-if="loading" class="loading">
<div class="spinner"></div>
<p>加载中...</p>
</div>
<!-- 大图标视图 - 单文件居中显示 -->
<!-- 单文件居中显示 -->
<div v-else-if="files.length === 1" class="single-file-container">
<div class="single-file-card">
<i class="single-file-icon fas" :class="getFileIcon(files[0])" :style="getIconColor(files[0])"></i>
<div class="single-file-name">{{ files[0].name }}</div>
<div class="single-file-size">{{ files[0].sizeFormatted }}</div>
<button v-if="!files[0].isDirectory" class="btn single-file-download" @click="downloadFile(files[0])">
<i class="fas fa-download"></i> 下载文件
</button>
</div>
</div>
<!-- 大图标视图 - 多文件网格显示 -->
<div v-else-if="viewMode === 'grid'" class="file-grid">
<div v-for="file in files" :key="file.name" class="file-grid-item"
@click="handleFileClick(file)"
@contextmenu="showFileContextMenu($event, file)"
@touchstart="startLongPress($event, file)"
@touchend="cancelLongPress"
@touchmove="cancelLongPress">
<i class="file-grid-icon fas" :class="getFileIcon(file)" :style="getIconColor(file)"></i>
<div class="file-grid-name" :title="file.name">{{ file.name }}</div>
<div class="file-grid-size">{{ file.sizeFormatted }}</div>
<button v-if="!file.isDirectory" class="btn btn-primary" @click.stop="downloadFile(file)" style="width: 100%;">
<i class="fas fa-download"></i> 下载
</button>
</div>
</div>
<!-- 列表视图 -->
<ul v-else class="file-list">
<li v-for="file in files" :key="file.name" class="file-item">
<div class="file-info">
<i class="file-icon fas" :class="getFileIcon(file)" :style="getIconColor(file)"></i>
<div>
<div style="font-weight: 500;">{{ file.name }}</div>
<div style="font-size: 12px; color: #999;">{{ file.sizeFormatted }}</div>
</div>
</div>
<button v-if="!file.isDirectory" class="btn btn-primary" @click.stop="downloadFile(file)">
<i class="fas fa-download"></i> 下载
</button>
</li>
</ul>
<p v-if="files.length === 0" style="text-align: center; color: #999; padding: 40px;">
暂无文件
</p>
</div>
<!-- 加载中 -->
<div v-else-if="loading" class="loading">
<div class="spinner"></div>
<p>加载中...</p>
</div>
</div>
</div>
</div>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
// API配置 - 动态适配localhost或生产环境
apiBase: window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? 'http://localhost:40001'
: window.location.protocol + '//' + window.location.host,
shareCode: '',
password: '',
needPassword: false,
verified: false,
shareNotFound: false,
shareInfo: null,
files: [],
loading: true,
errorMessage: '',
viewMode: "grid", // 视图模式: grid 大图标, list 列表(默认大图标)
// 媒体预览
showImageViewer: false,
showVideoPlayer: false,
showAudioPlayer: false,
currentMediaUrl: '',
currentMediaName: '',
currentMediaType: '', // 'image', 'video', 'audio'
// 右键菜单
showContextMenu: false,
contextMenuX: 0,
contextMenuY: 0,
contextMenuFile: null,
// 长按支持(移动端)
longPressTimer: null,
longPressFile: null
};
},
methods: {
async init() {
const urlParams = new URLSearchParams(window.location.search);
this.shareCode = urlParams.get('code');
if (!this.shareCode) {
this.errorMessage = '无效的分享链接';
this.loading = false;
return;
}
// 尝试验证分享
await this.verifyShare();
},
async verifyShare() {
this.errorMessage = '';
this.loading = true;
try {
const response = await axios.post(`${this.apiBase}/api/share/${this.shareCode}/verify`, {
password: this.password
});
if (response.data.success) {
this.verified = true;
this.shareInfo = response.data.share;
// 如果是单文件分享且后端已返回文件信息,直接使用,无需再次请求
if (response.data.file) {
this.files = [response.data.file];
this.loading = false;
} else {
// 目录分享,需要加载文件列表
await this.loadFiles();
}
}
} catch (error) {
// 404错误 - 分享不存在
if (error.response?.status === 404) {
this.shareNotFound = true;
this.loading = false;
}
// 需要密码
else if (error.response?.data?.needPassword) {
this.needPassword = true;
this.loading = false;
}
// 其他错误
else {
this.errorMessage = error.response?.data?.message || '验证失败';
this.loading = false;
}
}
},
async loadFiles() {
try {
const response = await axios.post(`${this.apiBase}/api/share/${this.shareCode}/list`, {
password: this.password,
path: ''
});
if (response.data.success) {
this.files = response.data.items;
}
} catch (error) {
console.error('加载文件失败:', error);
this.errorMessage = '加载文件失败';
} finally {
this.loading = false;
}
},
downloadFile(file) {
console.log("[分享下载] 文件:", file);
// 记录下载次数(异步,不等待)
axios.post(`${this.apiBase}/api/share/${this.shareCode}/download`)
.catch(err => console.error('记录下载次数失败:', err));
if (file.httpDownloadUrl) {
// 如果配置了HTTP下载URL使用HTTP直接下载
console.log("[分享下载] 使用HTTP下载:", file.httpDownloadUrl);
window.open(file.httpDownloadUrl, '_blank');
} else {
// 如果没有配置HTTP URL通过后端SFTP下载
console.log("[分享下载] 使用SFTP下载");
// 构建文件路径
let filePath;
if (this.shareInfo.share_type === 'file') {
// 单文件分享,使用 share_path
filePath = this.shareInfo.share_path;
} else {
// 目录分享,组合路径
const basePath = this.shareInfo.share_path;
filePath = basePath === '/' ? `/${file.name}` : `${basePath}/${file.name}`;
}
// 使用分享下载API公开API不需要认证
let downloadUrl = `${this.apiBase}/api/share/${this.shareCode}/download-file?path=${encodeURIComponent(filePath)}`;
// 如果有密码,附加密码参数
if (this.password) {
downloadUrl += `&password=${encodeURIComponent(this.password)}`;
}
window.open(downloadUrl, '_blank');
}
},
getFileIcon(file) {
if (file.isDirectory) return 'fa-folder';
if (file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg)$/i)) return 'fa-file-image';
if (file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv)$/i)) return 'fa-file-video';
if (file.name.match(/\.(mp3|wav|flac|aac|ogg)$/i)) return 'fa-file-audio';
if (file.name.match(/\.(pdf)$/i)) return 'fa-file-pdf';
if (file.name.match(/\.(doc|docx)$/i)) return 'fa-file-word';
if (file.name.match(/\.(xls|xlsx)$/i)) return 'fa-file-excel';
if (file.name.match(/\.(zip|rar|7z|tar|gz)$/i)) return 'fa-file-archive';
return 'fa-file';
},
getIconColor(file) {
if (file.isDirectory) return 'color: #FFC107;';
if (file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg)$/i)) return 'color: #4CAF50;';
if (file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv)$/i)) return 'color: #9C27B0;';
if (file.name.match(/\.(mp3|wav|flac|aac|ogg)$/i)) return 'color: #FF5722;';
if (file.name.match(/\.(pdf)$/i)) return 'color: #F44336;';
if (file.name.match(/\.(doc|docx)$/i)) return 'color: #2196F3;';
if (file.name.match(/\.(xls|xlsx)$/i)) return 'color: #4CAF50;';
if (file.name.match(/\.(zip|rar|7z|tar|gz)$/i)) return 'color: #795548;';
return 'color: #9E9E9E;';
},
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('zh-CN');
}
},
mounted() {
this.init();
}
}).mount('#app');
</script>
</body>
</html>

34
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,34 @@
server {
listen 80;
server_name localhost;
# 前端静态文件
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ =404;
}
# 后端API反向代理
location /api/ {
proxy_pass http://backend:40001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 使用上游传递的协议,如果没有则使用当前协议
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_cache_bypass $http_upgrade;
}
# 分享链接重定向
location /s/ {
proxy_pass http://backend:40001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
}

52
nginx/nginx.conf.example Normal file
View File

@@ -0,0 +1,52 @@
server {
listen 80;
server_name your-domain.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# 前端静态文件
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ =404;
}
# 后端API反向代理
location /api/ {
proxy_pass http://backend:40001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# 分享链接重定向
location /s/ {
proxy_pass http://backend:40001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

92
upload-tool/README.txt Normal file
View File

@@ -0,0 +1,92 @@
============================================
玩玩云上传工具 v2.0 使用说明
============================================
【新版本特性】
✨ 支持多文件上传
✨ 支持文件夹上传(递归扫描所有文件)
✨ 智能上传队列管理
✨ 自动检测可写目录(容错机制)
✨ 实时显示队列状态
【功能介绍】
本工具用于快速上传文件到您的SFTP服务器。
新版本支持批量上传和文件夹上传,大大提升工作效率!
【使用方法】
1. 双击运行"玩玩云上传工具.exe"
2. 等待程序连接服务器并测试上传目录
- 程序会自动测试多个目录的可写性
- 显示绿色✓表示连接成功
- 显示当前使用的上传目录
3. 拖拽文件或文件夹到窗口中
- 可以一次拖拽多个文件
- 可以拖拽整个文件夹(自动扫描所有文件)
- 混合拖拽也支持
4. 查看队列状态
- 界面显示"队列: X 个文件等待上传"
- 文件会按顺序依次上传
5. 实时查看上传进度
- 每个文件都有独立的进度显示
- 日志区域显示详细的上传信息
【目录容错机制】
程序会按以下优先级自动测试并选择可写目录:
1. /(根目录)
2. /upload
3. /uploads
4. /files
5. /home
6. /tmp
如果根目录没有写权限,程序会自动切换到其他可用目录。
【注意事项】
- 文件夹上传会递归扫描所有子文件夹
- 同名文件会被覆盖
- 上传大量文件时请确保网络稳定
- 所有文件会按顺序依次上传
- 上传目录会在启动时自动检测并显示
【界面说明】
- 拖拽区域:显示"支持多文件和文件夹"
- 队列状态:显示等待上传的文件数量
- 进度条:显示当前文件的上传进度
- 日志区域:显示详细的操作记录
【版本更新】
v2.0 (2025-11-09)
- ✅ 新增多文件上传支持
- ✅ 新增文件夹上传支持
- ✅ 新增上传队列管理
- ✅ 新增目录容错机制
- ✅ 优化界面显示
- ✅ 优化日志输出
v1.0
- 基础单文件上传功能
【常见问题】
Q: 支持上传多少个文件?
A: 理论上无限制,所有文件会加入队列依次上传
Q: 文件夹上传包括子文件夹吗?
A: 是的,会递归扫描所有子文件夹中的文件
Q: 上传目录是哪里?
A: 程序启动时会自动检测并显示在界面上
Q: 提示"API密钥无效或已过期"怎么办?
A: 请重新从网站下载最新的上传工具
Q: 上传速度慢怎么办?
A: 速度取决于您的网络和SFTP服务器性能
Q: 可以中途取消上传吗?
A: 当前版本暂不支持取消,请等待队列完成
【技术支持】
如有问题请联系管理员
============================================

52
upload-tool/build.bat Normal file
View File

@@ -0,0 +1,52 @@
@echo off
chcp 65001 > nul
echo ========================================
echo 玩玩云上传工具打包脚本
echo ========================================
echo.
REM 检查Python是否安装
python --version > nul 2>&1
if errorlevel 1 (
echo [错误] 未检测到Python请先安装Python 3.7+
pause
exit /b 1
)
echo [1/4] 安装依赖包...
pip install -r requirements.txt
if errorlevel 1 (
echo [错误] 依赖安装失败
pause
exit /b 1
)
echo.
echo [2/4] 安装PyInstaller...
pip install pyinstaller
if errorlevel 1 (
echo [错误] PyInstaller安装失败
pause
exit /b 1
)
echo.
echo [3/4] 打包程序...
pyinstaller --onefile --windowed --name="玩玩云上传工具" --icon=NONE upload_tool.py
if errorlevel 1 (
echo [错误] 打包失败
pause
exit /b 1
)
echo.
echo [4/4] 清理临时文件...
rmdir /s /q build
del /q *.spec
echo.
echo ========================================
echo 打包完成!
echo 输出文件: dist\玩玩云上传工具.exe
echo ========================================
pause

View File

@@ -0,0 +1,3 @@
PyQt5==5.15.9
paramiko==3.4.0
requests==2.31.0

499
upload-tool/upload_tool.py Normal file
View File

@@ -0,0 +1,499 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
import os
import json
import requests
import paramiko
from PyQt5.QtWidgets import (QApplication, QMainWindow, QLabel, QVBoxLayout,
QWidget, QProgressBar, QTextEdit, QPushButton)
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtGui import QDragEnterEvent, QDropEvent, QFont
class TestDirectoryThread(QThread):
"""测试目录可写性线程"""
result = pyqtSignal(bool, str) # 成功/失败,目录路径或错误信息
def __init__(self, test_dir, sftp_config):
super().__init__()
self.test_dir = test_dir
self.sftp_config = sftp_config
def run(self):
try:
transport = paramiko.Transport((
self.sftp_config['host'],
self.sftp_config['port']
))
transport.connect(
username=self.sftp_config['username'],
password=self.sftp_config['password']
)
sftp = paramiko.SFTPClient.from_transport(transport)
# 测试文件名
test_file = f"{self.test_dir}/.wwy_test_{os.getpid()}"
if not self.test_dir.endswith('/'):
test_file = f"{self.test_dir}/.wwy_test_{os.getpid()}"
else:
test_file = f"{self.test_dir}.wwy_test_{os.getpid()}"
# 尝试创建测试文件
try:
with sftp.open(test_file, 'w') as f:
f.write('test')
# 删除测试文件
try:
sftp.remove(test_file)
except:
pass
sftp.close()
transport.close()
self.result.emit(True, self.test_dir)
except Exception as e:
sftp.close()
transport.close()
self.result.emit(False, str(e))
except Exception as e:
self.result.emit(False, str(e))
class UploadThread(QThread):
"""上传线程"""
progress = pyqtSignal(int, str) # 进度,状态信息
finished = pyqtSignal(bool, str) # 成功/失败,消息
def __init__(self, sftp_config, file_path, remote_dir):
super().__init__()
self.sftp_config = sftp_config
self.file_path = file_path
self.remote_dir = remote_dir
def run(self):
try:
# 连接SFTP
self.progress.emit(10, f'正在连接服务器...')
transport = paramiko.Transport((
self.sftp_config['host'],
self.sftp_config['port']
))
transport.connect(
username=self.sftp_config['username'],
password=self.sftp_config['password']
)
sftp = paramiko.SFTPClient.from_transport(transport)
self.progress.emit(30, f'连接成功,开始上传...')
# 获取文件名
filename = os.path.basename(self.file_path)
# 构建远程路径
if self.remote_dir.endswith('/'):
remote_path = f'{self.remote_dir}{filename}'
else:
remote_path = f'{self.remote_dir}/{filename}'
# 上传文件(带进度)- 使用临时文件避免.fuse_hidden问题
file_size = os.path.getsize(self.file_path)
uploaded = 0
# 使用临时文件名
import time
temp_remote_path = f'{remote_path}.uploading_{int(time.time() * 1000)}'
def callback(transferred, total):
nonlocal uploaded
uploaded = transferred
percent = int((transferred / total) * 100) if total > 0 else 0
# 进度从30%到90%
progress_value = 30 + int(percent * 0.6)
# 计算速度
size_mb = transferred / (1024 * 1024)
self.progress.emit(progress_value, f'上传中: {filename} ({size_mb:.2f} MB / {total/(1024*1024):.2f} MB)')
# 第一步:上传到临时文件
sftp.put(self.file_path, temp_remote_path, callback=callback)
# 第二步:删除旧文件(如果存在)
try:
sftp.stat(remote_path)
sftp.remove(remote_path)
except:
pass # 文件不存在,无需删除
# 第三步:重命名临时文件为目标文件
sftp.rename(temp_remote_path, remote_path)
# 关闭连接
sftp.close()
transport.close()
self.progress.emit(100, f'上传完成!')
self.finished.emit(True, f'文件 {filename} 上传成功!')
except Exception as e:
self.finished.emit(False, f'上传失败: {str(e)}')
class UploadWindow(QMainWindow):
def __init__(self):
super().__init__()
self.config = self.load_config()
self.sftp_config = None
self.remote_dir = '/' # 默认上传目录
self.upload_queue = [] # 上传队列
self.is_uploading = False # 是否正在上传
self.initUI()
self.get_sftp_config()
def load_config(self):
"""加载配置文件"""
try:
# PyInstaller打包后使用sys._MEIPASS
if getattr(sys, 'frozen', False):
# 打包后的exe
base_path = os.path.dirname(sys.executable)
else:
# 开发环境
base_path = os.path.dirname(__file__)
config_path = os.path.join(base_path, 'config.json')
if not os.path.exists(config_path):
from PyQt5.QtWidgets import QMessageBox
QMessageBox.critical(None, '错误', f'找不到配置文件: {config_path}\n\n请确保config.json与程序在同一目录下')
sys.exit(1)
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
from PyQt5.QtWidgets import QMessageBox
QMessageBox.critical(None, '错误', f'加载配置失败:\n{str(e)}')
sys.exit(1)
def get_sftp_config(self):
"""从服务器获取SFTP配置"""
try:
response = requests.post(
f"{self.config['api_base_url']}/api/upload/get-config",
json={'api_key': self.config['api_key']},
timeout=10
)
if response.status_code == 200:
data = response.json()
if data['success']:
self.sftp_config = data['sftp_config']
# 自动测试并设置上传目录
self.test_and_set_upload_directory()
else:
self.show_error(data.get('message', '获取配置失败'))
else:
self.show_error(f'服务器错误: {response.status_code}')
except Exception as e:
self.show_error(f'无法连接到服务器: {str(e)}')
def test_and_set_upload_directory(self):
"""测试并设置上传目录"""
if not self.sftp_config:
return
self.log('开始测试上传目录...')
self.status_label.setText(
f'<h2>玩玩云上传工具 v2.0</h2>'
f'<p style="color: orange;">正在测试上传目录...</p>'
)
# 按优先级测试目录
self.test_dirs = ['/', '/upload', '/uploads', '/files', '/home', '/tmp']
self.current_test_index = 0
self.test_next_directory()
def test_next_directory(self):
"""测试下一个目录"""
if self.current_test_index >= len(self.test_dirs):
self.log('✗ 所有目录都无法写入请检查SFTP权限')
self.show_error('无法找到可写入的目录请检查SFTP权限')
return
test_dir = self.test_dirs[self.current_test_index]
self.log(f'测试目录: {test_dir}')
self.test_thread = TestDirectoryThread(test_dir, self.sftp_config)
self.test_thread.result.connect(self.on_test_result)
self.test_thread.start()
def on_test_result(self, success, message):
"""处理测试结果"""
if success:
self.remote_dir = message
self.log(f'✓ 已设置上传目录为: {message}')
self.status_label.setText(
f'<h2>玩玩云上传工具 v2.0</h2>'
f'<p style="color: green;">✓ 已连接 - 用户: {self.config["username"]}</p>'
f'<p style="color: #666; font-size: 14px;">拖拽文件/文件夹到此处上传</p>'
f'<p style="color: #999; font-size: 12px;">上传目录: {self.remote_dir}</p>'
)
else:
self.log(f'✗ 目录 {self.test_dirs[self.current_test_index]} 不可写: {message}')
self.current_test_index += 1
self.test_next_directory()
def show_error(self, message):
"""显示错误信息"""
self.status_label.setText(
f'<h2>玩玩云上传工具 v2.0</h2>'
f'<p style="color: red;">✗ 错误: {message}</p>'
f'<p style="color: #666; font-size: 14px;">请检查网络连接或联系管理员</p>'
)
def initUI(self):
"""初始化界面"""
self.setWindowTitle('玩玩云上传工具 v2.0')
self.setGeometry(300, 300, 500, 450)
# 设置接受拖拽
self.setAcceptDrops(True)
# 中心部件
central_widget = QWidget()
self.setCentralWidget(central_widget)
# 布局
layout = QVBoxLayout()
# 状态标签
self.status_label = QLabel('正在连接服务器...')
self.status_label.setAlignment(Qt.AlignCenter)
self.status_label.setFont(QFont('Arial', 11))
self.status_label.setWordWrap(True)
self.status_label.setStyleSheet('padding: 20px;')
layout.addWidget(self.status_label)
# 拖拽提示区域
self.drop_area = QLabel('📁\n\n支持多文件和文件夹')
self.drop_area.setAlignment(Qt.AlignCenter)
self.drop_area.setStyleSheet("""
QLabel {
font-size: 50px;
color: #667eea;
border: 3px dashed #667eea;
border-radius: 10px;
background-color: #f5f7fa;
padding: 40px;
}
""")
layout.addWidget(self.drop_area)
# 队列状态标签
self.queue_label = QLabel('队列: 0 个文件等待上传')
self.queue_label.setAlignment(Qt.AlignCenter)
self.queue_label.setStyleSheet('color: #2c3e50; font-weight: bold; padding: 5px;')
layout.addWidget(self.queue_label)
# 进度条
self.progress_bar = QProgressBar()
self.progress_bar.setValue(0)
self.progress_bar.setVisible(False)
self.progress_bar.setStyleSheet("""
QProgressBar {
border: 2px solid #e0e0e0;
border-radius: 5px;
text-align: center;
height: 25px;
}
QProgressBar::chunk {
background-color: #667eea;
}
""")
layout.addWidget(self.progress_bar)
# 进度信息
self.progress_label = QLabel('')
self.progress_label.setAlignment(Qt.AlignCenter)
self.progress_label.setVisible(False)
layout.addWidget(self.progress_label)
# 日志区域
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setMaximumHeight(100)
self.log_text.setStyleSheet("""
QTextEdit {
background-color: #f9f9f9;
border: 1px solid #e0e0e0;
border-radius: 5px;
padding: 5px;
font-family: 'Courier New', monospace;
font-size: 11px;
}
""")
layout.addWidget(self.log_text)
central_widget.setLayout(layout)
self.log('程序已启动 - 版本 v2.0')
def log(self, message):
"""添加日志"""
self.log_text.append(f'[{self.get_time()}] {message}')
# 自动滚动到底部
self.log_text.verticalScrollBar().setValue(
self.log_text.verticalScrollBar().maximum()
)
def get_time(self):
"""获取当前时间"""
from datetime import datetime
return datetime.now().strftime('%H:%M:%S')
def dragEnterEvent(self, event: QDragEnterEvent):
"""拖拽进入事件"""
if event.mimeData().hasUrls():
event.acceptProposedAction()
self.drop_area.setStyleSheet("""
QLabel {
font-size: 50px;
color: #667eea;
border: 3px dashed #667eea;
border-radius: 10px;
background-color: #e8ecf7;
padding: 40px;
}
""")
def dragLeaveEvent(self, event):
"""拖拽离开事件"""
self.drop_area.setStyleSheet("""
QLabel {
font-size: 50px;
color: #667eea;
border: 3px dashed #667eea;
border-radius: 10px;
background-color: #f5f7fa;
padding: 40px;
}
""")
def dropEvent(self, event: QDropEvent):
"""拖拽放下事件"""
self.drop_area.setStyleSheet("""
QLabel {
font-size: 50px;
color: #667eea;
border: 3px dashed #667eea;
border-radius: 10px;
background-color: #f5f7fa;
padding: 40px;
}
""")
if not self.sftp_config:
self.log('错误: 未获取到SFTP配置')
return
paths = [url.toLocalFile() for url in event.mimeData().urls()]
all_files = []
for path in paths:
if os.path.isfile(path):
all_files.append(path)
elif os.path.isdir(path):
self.log(f'扫描文件夹: {os.path.basename(path)}')
folder_files = self.scan_folder(path)
all_files.extend(folder_files)
self.log(f'找到 {len(folder_files)} 个文件')
if all_files:
self.upload_queue.extend(all_files)
self.update_queue_label()
self.log(f'添加 {len(all_files)} 个文件到上传队列')
if not self.is_uploading:
self.process_upload_queue()
def scan_folder(self, folder_path):
"""递归扫描文件夹"""
files = []
try:
for root, dirs, filenames in os.walk(folder_path):
for filename in filenames:
file_path = os.path.join(root, filename)
files.append(file_path)
except Exception as e:
self.log(f'扫描文件夹失败: {str(e)}')
return files
def update_queue_label(self):
"""更新队列标签"""
count = len(self.upload_queue)
self.queue_label.setText(f'队列: {count} 个文件等待上传')
def process_upload_queue(self):
"""处理上传队列"""
if not self.upload_queue:
self.is_uploading = False
self.update_queue_label()
self.log('✓ 所有文件上传完成!')
return
self.is_uploading = True
file_path = self.upload_queue.pop(0)
self.update_queue_label()
self.upload_file(file_path)
def upload_file(self, file_path):
"""上传文件"""
self.log(f'开始上传: {os.path.basename(file_path)}')
# 显示进度控件
self.progress_bar.setVisible(True)
self.progress_bar.setValue(0)
self.progress_label.setVisible(True)
self.progress_label.setText('准备上传...')
# 创建上传线程
self.upload_thread = UploadThread(self.sftp_config, file_path, self.remote_dir)
self.upload_thread.progress.connect(self.on_progress)
self.upload_thread.finished.connect(self.on_finished)
self.upload_thread.start()
def on_progress(self, value, message):
"""上传进度更新"""
self.progress_bar.setValue(value)
self.progress_label.setText(message)
def on_finished(self, success, message):
"""上传完成"""
self.log(message)
if success:
self.progress_label.setText('' + message)
self.progress_label.setStyleSheet('color: green; font-weight: bold;')
else:
self.progress_label.setText('' + message)
self.progress_label.setStyleSheet('color: red; font-weight: bold;')
# 继续处理队列
from PyQt5.QtCore import QTimer
QTimer.singleShot(1000, self.process_upload_queue)
def main():
app = QApplication(sys.argv)
window = UploadWindow()
window.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()