Author SHA1 Message Date
237899745 bd46227e49 fix: harden one-click installer
Build and tests / test (push) Has been cancelled
2026-07-27 21:04:12 +08:00
237899745 a736d70d57 build: publish desktop installer via Gitea releases
Build and tests / test (push) Has been cancelled
2026-07-27 15:20:09 +08:00
237899745 f760aa0ae9 fix: deduplicate proxied security headers 2026-07-27 13:45:22 +08:00
237899745 6b7b79c7cb build: reuse cached Alpine toolchain layer 2026-07-27 13:35:41 +08:00
237899745 9f19fe1824 fix: make backend Docker builds reproducible 2026-07-27 13:24:28 +08:00
237899745 abad4d4c16 fix: preserve backend Docker context exclusions 2026-07-27 13:16:26 +08:00
237899745 39fca59cc4 fix: keep runtime state out of backend images 2026-07-27 13:15:54 +08:00
237899745 f5377e51bb chore: harden build and deployment workflow 2026-07-27 13:07:14 +08:00
237899745 6692736e88 build: add Vite web production pipeline 2026-07-27 13:07:10 +08:00
237899745 dce1e3622a refactor: modularize backend security and observability 2026-07-27 13:06:54 +08:00
237899745 f90311e68c fix: harden backend stability and test production utilities 2026-07-27 12:17:57 +08:00
237899745 2c5a22fad8 merge: unify main into master (master content is authoritative)
main was a 4-commit January 2026 orphan snapshot that also committed
node_modules/, backend/.env and backend/data/database.db. Its only
source features (OSS traffic stats via @alicloud/cms20190101, /s/:code
share route, quota display fixes) are all superseded by master's
360-commit history — master implements download traffic via the
reservation + OSS log reconcile system instead.

Merge records main's history as an ancestor; the resulting tree is
master's unchanged.
2026-07-27 11:39:52 +08:00
237899745 d07c08c609 feat: refine web workspace UI 2026-07-21 17:11:18 +08:00
237899745 465be72579 feat: unify cloud workspace and release desktop v0.1.38 2026-07-21 16:16:17 +08:00
237899745 e6f3556ab7 fix: harden cloud storage security 2026-06-13 18:45:12 +08:00
237899745andClaude Opus 4.6 318d8f32ab chore: bump version to 0.1.30 and release
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:49:29 +08:00
237899745andClaude Opus 4.6 039f1d8269 fix: add CSRF token to delete direct link request
The DELETE /api/direct-link/:id endpoint requires CSRF validation.
Updated api_delete_direct_link to fetch CSRF token before sending
the request, matching the pattern used by api_delete_share.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:44:05 +08:00
237899745andClaude Opus 4.6 a82f7b7e36 chore: bump version to 0.1.29 and release
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:38:15 +08:00
237899745andClaude Opus 4.6 c9349bd116 perf: async OSS upload in resumable complete endpoint
For OSS storage, the /api/upload/resumable/complete endpoint now returns
immediately after all chunks are assembled, then uploads to OSS in the
background. This eliminates the blocking re-upload delay for large files.

Local storage remains synchronous (already optimized with rename-first).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:26:02 +08:00
237899745andClaude Opus 4.6 4832d7ac46 feat: optimize file icons, UI, share direct links, and upload performance
- Replace CSS-only file icons with SVG icons for each file type (folder, image, video, audio, archive, document, app)
- Add navigation icons to left sidebar (Baidu Netdisk style)
- Enlarge file cards (108px -> 120px) with smoother transitions
- Add direct links section to share page with copy/delete actions
- Add Rust bridge commands for fetching and deleting direct links
- Optimize local storage put() to use rename-first strategy instead of copyFileSync for instant large file completion
- Show "server processing" status during upload finalization instead of appearing stuck

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:19:20 +08:00
yuyx 0151cacc25 fix(settings): 移除设置中心的客户端下载入口
右上角导航已有客户端下载按钮,设置中心重复展示无必要
2026-04-03 22:54:49 +08:00
19174616018 069f2f91ac fix: improve app relaunch after update and release 0.1.28 2026-02-20 23:53:02 +08:00
19174616018 fbf1244722 feat: improve sync workspace and updater stability in 0.1.27 2026-02-20 23:44:26 +08:00
19174616018 f03a8b4524 fix: make silent updater produce logs reliably and release 0.1.26 2026-02-20 22:55:20 +08:00
19174616018 636aa69ede feat: verify updater package and harden client reliability in 0.1.25 2026-02-20 22:15:28 +08:00
19174616018 34eedc6a5b fix: harden silent updater flow and release 0.1.24 2026-02-20 20:34:02 +08:00
19174616018 7025c1072f feat: improve upload resilience and release 0.1.23 2026-02-20 20:21:42 +08:00
19174616018 ce784ff77f fix: correct mobile web layout for client download button 2026-02-20 16:15:54 +08:00
19174616018 8abb08a744 feat: add web entry to download desktop client 2026-02-19 21:49:32 +08:00
19174616018 8bc32b3f72 fix: stabilize silent updater and release 0.1.22 2026-02-19 21:41:44 +08:00
19174616018 eccc74d1a9 chore: bump desktop update channel to 0.1.21 2026-02-19 21:04:12 +08:00
19174616018 2845eeea24 feat: tighten file card borders on web and desktop with 0.1.10 release 2026-02-19 20:57:24 +08:00
19174616018 e3ed4df356 feat: restyle desktop file grid to baidu-style icon layout 2026-02-19 20:46:31 +08:00
19174616018 cb6e9a67c1 feat: release desktop 0.1.9 with device dedupe and new branding 2026-02-19 20:33:54 +08:00
19174616018 d5b45c7aef feat: adjust confirm button order and improve file name wrapping 2026-02-19 20:09:05 +08:00
19174616018 14bc3f98fb chore: release desktop client 0.1.7 2026-02-19 19:44:15 +08:00
19174616018 f17dc2fda2 fix: unify client confirmations and inline rename UX 2026-02-19 19:36:52 +08:00
19174616018 c595cf28cb chore: bump desktop client version to 0.1.6 2026-02-19 19:14:35 +08:00
19174616018 6cdac7ddfb fix: restore login by defining getClientIp helper 2026-02-19 19:08:12 +08:00
19174616018 f253d542d9 feat: add online device management and desktop settings integration 2026-02-19 17:34:41 +08:00
19174616018 85039fcd29 feat(desktop): remember login in sqlite and streamline update flow 2026-02-19 00:12:33 +08:00
19174616018 2c30ae12e3 chore(release): bump desktop client to 0.1.4 2026-02-18 22:55:52 +08:00
19174616018 7549361d0a fix(desktop): require in-app confirmation before deleting shares 2026-02-18 22:53:53 +08:00
19174616018 0870bae3f2 fix(frontend): replace share delete confirm with in-app modal 2026-02-18 22:48:31 +08:00
19174616018 9dd2b2774b chore(release): bump desktop client to 0.1.3 2026-02-18 22:27:20 +08:00
19174616018 e40b9ff82c feat(desktop): stream download progress and auto-launch installer 2026-02-18 22:24:41 +08:00
19174616018 8297bee1ea docs(desktop): rewrite README in Chinese with integration and build guide 2026-02-18 22:16:20 +08:00
19174616018 f88505b829 feat(update): auto-clean old desktop installer packages 2026-02-18 22:14:26 +08:00
19174616018 134025b7a5 feat(share): reuse existing share and direct links per file 2026-02-18 22:13:14 +08:00
19174616018 18d92f37aa fix(frontend): use native confirm for share/direct-link deletion 2026-02-18 22:11:55 +08:00
19174616018 a3b8e4249a chore(release): publish desktop 0.1.2 with manual update checks 2026-02-18 22:02:34 +08:00
19174616018 e5c4b8f418 feat(security): shorten download signed URLs to 30s and remove update polling 2026-02-18 21:59:14 +08:00
19174616018 8617856388 chore(release): sync tauri cargo lock to 0.1.1 2026-02-18 21:25:47 +08:00
19174616018 7db94bf23c chore(release): bump desktop client to 0.1.1 2026-02-18 21:23:05 +08:00
19174616018 b560da04c7 feat(desktop): implement startup and scheduled auto update flow 2026-02-18 21:11:59 +08:00
19174616018 b0490e360f fix(desktop-ui): stabilize files toolbar layout 2026-02-18 21:08:18 +08:00
19174616018 0cff938756 chore(desktop): align update panel copy with one-click installer flow 2026-02-18 20:28:08 +08:00
19174616018 46b7f5dfcf feat(desktop): align requested 4/5/6/8 with resume queue batch and one-click update 2026-02-18 20:26:16 +08:00
19174616018 62c8fdc200 feat(desktop): add sort/filter, update center, and local sync workspace 2026-02-18 20:07:21 +08:00
19174616018 f1654165b8 perf(desktop): stream drag-upload and improve transfer status UX 2026-02-18 19:50:34 +08:00
19174616018 1776f8eb6d feat(desktop): add drag-and-drop upload for file view 2026-02-18 19:46:11 +08:00
19174616018 5c3fb759b3 style(desktop): improve alignment and spacing across file/share views 2026-02-18 19:33:39 +08:00
19174616018 e3d3823639 fix(desktop): enforce square file cards and icon tiles 2026-02-18 19:29:25 +08:00
19174616018 1d34ada9b7 feat(desktop): native download and working context menu actions 2026-02-18 19:25:52 +08:00
19174616018 3846c489e6 feat(desktop): square file cards and context-menu file actions 2026-02-18 18:30:53 +08:00
19174616018 3a0040793d feat(desktop): implement share management and hide endpoint settings 2026-02-18 17:17:49 +08:00
19174616018 a45aaf59ea feat(desktop): add tauri desktop client for cs.workyai.cn 2026-02-18 16:53:22 +08:00
19174616018 9b3abdbe6a fix: correct local datetime display and remove false devtools detection 2026-02-18 11:23:34 +08:00
19174616018 6b26b73ab0 fix: keep expired reservations reconcilable for delayed OSS logs 2026-02-18 10:49:58 +08:00
19174616018 2bd74b9eeb fix: ingest oss traffic logs without file extensions 2026-02-18 10:24:00 +08:00
19174616018 5688057607 fix: bump app.js cache busting version 2026-02-18 10:08:11 +08:00
19174616018 863c23f946 feat: add configurable stealth download security policies 2026-02-18 09:48:14 +08:00
19174616018 51b84d4966 fix: improve reservation cleanup and share popup handling 2026-02-17 23:55:31 +08:00
19174616018 01b15e1c28 feat: add share security, resumable upload, global search and reservation ops panel 2026-02-17 23:36:30 +08:00
19174616018 78f5f02f83 fix: bump app.js cache-busting version 2026-02-17 22:56:49 +08:00
19174616018 8d6ffa3448 ui: show file names instead of full paths in shares 2026-02-17 22:54:12 +08:00
19174616018 71733a5efc style: align share and direct-link table layout 2026-02-17 22:42:11 +08:00
19174616018 88a64b44cd fix: unify share/direct link click and copy actions 2026-02-17 22:39:19 +08:00
19174616018 d003ab711e fix: normalize traffic range buttons layout in settings 2026-02-17 22:14:40 +08:00
19174616018 4d226f3b86 feat: add independent direct-link sharing flow 2026-02-17 21:57:38 +08:00
19174616018 77bdda5fdb test: update admin/share edge scripts for cookie+csrf auth 2026-02-17 21:32:07 +08:00
19174616018 fede6c3b70 feat: add server-side admin user pagination and align traffic report accounting 2026-02-17 20:30:02 +08:00
19174616018 0791082fde feat: improve admin user management with filters and pagination 2026-02-17 20:13:32 +08:00
19174616018 807d5e5fcb feat: improve media preview UX with caching and loading states 2026-02-17 20:03:02 +08:00
19174616018 3c86e18bad fix: remove preview content-type override for aliyun oss compatibility 2026-02-17 19:51:01 +08:00
19174616018 60e7e5bb78 fix: use preview-mode signed URLs and graceful media preview fallback 2026-02-17 19:36:49 +08:00
19174616018 d6ceac5dbe fix: precheck local downloads to avoid JSON file download on quota errors 2026-02-17 19:32:48 +08:00
19174616018 3ace94d767 fix: fallback to file icon when thumbnail load fails 2026-02-17 19:29:42 +08:00
19174616018 0c660f65d6 feat: make zero download quota block downloads and use -1 for unlimited 2026-02-17 19:25:39 +08:00
19174616018 725c252100 fix: precheck local share download quota at download-url stage 2026-02-17 19:08:47 +08:00
19174616018 b93547d111 chore: properly ignore runtime storage and data directories 2026-02-17 19:07:11 +08:00
19174616018 ef67cd2a44 fix: move share quota block to download and add 3s download alert 2026-02-17 19:05:12 +08:00
19174616018 7983ab504a feat: switch OSS download quota to reservation plus log reconcile 2026-02-17 18:12:33 +08:00
19174616018 5cdd8e9c84 fix: force OSS direct download even when traffic quota is enabled 2026-02-17 17:40:55 +08:00
19174616018 536e807b38 feat: add user download traffic reports and restore OSS direct downloads 2026-02-17 17:36:26 +08:00
19174616018 753434ff49 feat: enhance download traffic quota lifecycle controls 2026-02-17 17:19:25 +08:00
19174616018 83937db680 feat(quota): add downloadable traffic quota with local/OSS/share metering 2026-02-17 16:52:26 +08:00
19174616018 854b1374ab fix(security): harden CORS/cookie policy and share path validation 2026-02-12 21:39:01 +08:00
19174616018 7cab8516de fix(ui): apply true large-screen scaling and mobile overflow safeguards 2026-02-12 20:33:36 +08:00
19174616018 63392ea1b2 fix(frontend): improve 2k/4k scaling and mobile overflow responsiveness 2026-02-12 20:28:08 +08:00
19174616018 08b3c41d79 feat: apply UI/storage/share optimizations and quota improvements 2026-02-12 18:02:57 +08:00
19174616018 05fc04258d feat(frontend): unify landing style and add product/scenes/start pages 2026-02-12 18:02:28 +08:00
19174616018andClaude Opus 4.5 b135987fe8 feat: 添加多项功能和修复
功能新增:
- OSS 存储使用情况显示(文件页面)
- OSS 当日流量统计(阿里云云监控API)
- 分享页面路由修复(/s/xxx 格式支持)

Bug修复:
- 修复分享页面资源路径(相对路径改绝对路径)
- 修复分享码获取逻辑(支持路径格式)
- 修复OSS配额undefined显示问题
- 修复登录流程OSS配置检查
- 修复文件数为null时的显示问题

依赖更新:
- 添加 @alicloud/cms20190101 云监控SDK
- 添加 @alicloud/openapi-client

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:04:22 +08:00
19174616018andClaude Opus 4.5 a86903fcdc fix: 修复普通用户登录和访问文件时的OSS配置检查
- 登录流程:当没有OSS配置时显示友好警告而不是权限错误
- 文件访问:在调用API前检查OSS配置,避免后端报错
- 更新JS版本号强制刷新缓存

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:50:29 +08:00
19174616018andClaude Opus 4.5 6c283efffb fix: 更新JS版本号强制刷新缓存
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:44:36 +08:00
19174616018andClaude Opus 4.5 4350113979 fix: 修复配额说明重复和undefined问题
- 在editStorageForm中初始化oss_storage_quota_value和oss_quota_unit
- 删除重复的旧配额说明块,保留新的当前配额设置显示

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:39:53 +08:00
19174616018andClaude Opus 4.5 4809be5bae chore: 移除系统设置的密码二次验证
移除 /api/admin/settings 路由的 requirePasswordConfirmation 中间件,
简化管理员操作流程。系统设置更新现在仅依赖管理员登录认证。

注意:此修改降低了安全性,建议在生产环境中考虑其他安全措施。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 11:58:39 +08:00
19174616018andClaude Opus 4.5 ed383e596d fix: 部署脚本添加 ENCRYPTION_KEY 和 ENABLE_CSRF 配置
修复问题:
1. 新安装时自动生成 ENCRYPTION_KEY(用于加密 OSS 敏感信息)
2. 新安装时默认启用 CSRF 保护(ENABLE_CSRF=true)
3. 升级时自动检查并补充缺失的 ENCRYPTION_KEY 和 ENABLE_CSRF

解决了部署后服务因缺少 ENCRYPTION_KEY 而无法启动的问题。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 11:50:25 +08:00
Dev Team 2e3f4b2d9b fix: 隐藏系统级统一OSS用户的OSS配置按钮
## 问题
用户权限为 oss_only 时仍显示"配置/修改OSS"按钮,但用户使用的是系统级统一OSS配置,
不需要也无法修改个人OSS配置。

## 修复
- app.html:1894 - 添加条件判断 `v-if="user?.has_oss_config"`
- 仅在用户有个人OSS配置时显示"修改个人OSS配置"按钮
- 修改按钮文本:"配置/修改OSS" → "修改个人OSS配置"
- 修改说明文本:"已配置云服务" → "已配置系统级OSS"

## 影响
-  系统级统一OSS用户不再看到误导性的配置按钮
-  有个人OSS配置的用户仍可以修改个人配置
-  提升用户体验,避免混淆

**Bug数量:** 1个UI问题
**修改文件:** 1个
2026-01-20 22:52:09 +08:00
Dev Team d54e767a53 fix: 修复OSS删除单个文件失败的bug
## 问题
删除单个文件时使用DeleteObjectsCommand导致阿里云OSS报错:
"Missing Some Required Arguments."

## 修复
- 改用DeleteObjectCommand删除单个文件
- 修复storage.js:1224的delete方法
- 与之前修复的rename方法保持一致

## 影响
-  文件删除功能现在正常工作
-  与重命名功能使用相同的删除命令
-  完全兼容阿里云OSS

**Bug数量:** 1个
**修改文件:** 1个
2026-01-20 22:24:05 +08:00
Dev Team 3795158a11 fix: 全面修复系统级统一OSS配置的12个关键bug
## 修复内容

### 后端API修复(server.js)
- 添加oss_config_source字段到登录响应,用于前端判断OSS直连上传
- 修复6个API未检查系统级统一OSS配置的问题:
  * upload-signature: 使用effectiveBucket支持系统配置
  * upload-complete: 添加OSS配置安全检查
  * oss-usage/oss-usage-full: 检查系统级配置
  * switch-storage: 改进OSS配置检查逻辑
  * 5个管理员API: storage-cache检查/重建/修复功能

### 存储客户端修复(storage.js)
- rename方法: 使用getBucket()支持系统级统一配置
- stat方法: 使用getBucket()替代user.oss_bucket
- 重命名操作: 改用DeleteObjectCommand替代DeleteObjectsCommand
  * 修复阿里云OSS"Missing Some Required Arguments"错误
  * 解决重命名后旧文件无法删除的问题
- put方法: 改用Buffer上传替代流式上传
  * 避免AWS SDK的aws-chunked编码问题
  * 提升阿里云OSS兼容性
- 添加阿里云OSS特定配置:
  * disableNormalizeBucketName: true
  * checksumValidation: false

### 存储缓存修复(utils/storage-cache.js)
- resetUsage方法: 改用直接SQL更新,绕过UserDB字段白名单限制
  * 修复缓存重建失败的问题
- 3个方法改用ossClient.getBucket():
  * validateAndFix
  * checkIntegrity
  * rebuildCache
- checkAllUsersIntegrity: 添加系统级配置检查

### 前端修复(app.js)
- 上传路由: 使用oss_config_source判断而非has_oss_config
- 下载/预览: 统一使用oss_config_source
- 确保系统级统一OSS用户可以直连上传/下载

### 安装脚本优化(install.sh)
- 清理并优化安装流程

## 影响范围

**关键修复:**
-  系统级统一OSS配置现在完全可用
-  文件重命名功能正常工作(旧文件会被正确删除)
-  存储使用量缓存正确显示和更新
-  所有管理员功能支持系统级统一OSS
-  上传完成API不再有安全漏洞

**修复的Bug数量:** 12个核心bug
**修改的文件:** 6个
**代码行数:** +154 -264

## 测试验证

-  用户2存储使用量: 143.79 MB(已重建缓存)
-  文件重命名: 旧文件正确删除
-  管理员功能: 缓存检查/重建/修复正常
-  上传功能: 直连OSS,缓存正确更新
-  多用户: 用户3已激活并可正常使用
2026-01-20 22:23:37 +08:00
Dev Team a6fc6c4e58 feat: 删除SFTP上传工具,修复OSS配置bug
主要变更:
- 删除管理员工具栏及上传工具相关功能(后端API + 前端UI)
- 删除upload-tool目录及相关文件
- 修复OSS配置测试连接bug(testUser缺少has_oss_config标志)
- 新增backend/utils加密和缓存工具模块
- 更新.gitignore排除测试报告文件

技术改进:
- 统一使用OSS存储,废弃SFTP上传方式
- 修复OSS配置保存和测试连接时的错误处理
- 完善代码库文件管理,排除临时报告文件
2026-01-20 20:41:18 +08:00
19174616018andClaude Opus 4.5 a30b3cac77 fix: 自动生成 SESSION_SECRET 配置
- 新安装时自动生成随机 SESSION_SECRET
- 更新时自动补充缺失的 SESSION_SECRET
- 避免生产环境因缺少密钥而启动失败

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 11:00:17 +08:00
19174616018andClaude Opus 4.5 8eee63436c feat: 全面优化代码质量至 8.55/10 分
## 安全增强
- 添加 CSRF 防护机制(Double Submit Cookie 模式)
- 增强密码强度验证(8字符+两种字符类型)
- 添加 Session 密钥安全检查
- 修复 .htaccess 文件上传漏洞
- 统一使用 getSafeErrorMessage() 保护敏感错误信息
- 增强数据库原型污染防护
- 添加被封禁用户分享访问检查

## 功能修复
- 修复模态框点击外部关闭功能
- 修复 share.html 未定义方法调用
- 修复 verify.html 和 reset-password.html API 路径
- 修复数据库 SFTP->OSS 迁移逻辑
- 修复 OSS 未配置时的错误提示
- 添加文件夹名称长度限制
- 添加文件列表 API 路径验证

## UI/UX 改进
- 添加 6 个按钮加载状态(登录/注册/修改密码等)
- 将 15+ 处 alert() 替换为 Toast 通知
- 添加防重复提交机制(创建文件夹/分享)
- 优化 loadUserProfile 防抖调用

## 代码质量
- 消除 formatFileSize 重复定义
- 集中模块导入到文件顶部
- 添加 JSDoc 注释
- 创建路由拆分示例 (routes/)

## 测试套件
- 添加 boundary-tests.js (60 用例)
- 添加 network-concurrent-tests.js (33 用例)
- 添加 state-consistency-tests.js (38 用例)
- 添加 test_share.js 和 test_admin.js

## 文档和配置
- 新增 INSTALL_GUIDE.md 手动部署指南
- 新增 VERSION.txt 版本历史
- 完善 .env.example 配置说明
- 新增 docker-compose.yml
- 完善 nginx.conf.example

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:45:51 +08:00
19174616018andClaude Opus 4.5 e4d418645c fix: 全面修复和优化 OSS 功能
## 安全修复
- 修复 /api/user/profile 接口泄露 OSS 密钥的安全漏洞
- 增强 getObjectKey 路径安全检查(空字节注入、URL 编码绕过)
- 修复 storage.end() 重复调用问题
- 增强上传签名接口的安全检查

## Bug 修复
- 修复 rename 使用错误的 PutObjectCommand,改为 CopyObjectCommand
- 修复 CopySource 编码问题,正确处理特殊字符
- 修复签名 URL 生成功能(添加 @aws-sdk/s3-request-presigner)
- 修复 S3Client 配置(阿里云 region 格式、endpoint 处理)
- 修复分页删除和列表功能(超过 1000 文件的处理)
- 修复分享下载使用错误的存储类型字段
- 修复前端媒体预览异步处理错误
- 修复 OSS 直传 objectKey 格式不一致问题
- 修复包名错误 @aws-sdk/request-presigner -> @aws-sdk/s3-request-presigner
- 修复前端下载错误处理不完善

## 新增功能
- 添加 OSS 连接测试 API (/api/user/test-oss)
- 添加重命名失败回滚机制
- 添加 OSS 配置前端验证

## 其他改进
- 更新 install.sh 仓库地址为 git.workyai.cn
- 添加 crypto 模块导入
- 修复代码格式和重复定义问题
- 添加缺失的表单对象定义

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 09:46:00 +08:00
Claude OpusandClaude Opus 4.5 70c67543a9 fix: 完善存储客户端实现并删除重复导入
- LocalStorageClient 和 OssStorageClient 添加 formatSize() 方法
- 删除 mkdir() 中重复的 PutObjectCommand 导入
- 统一使用共享的 formatFileSize() 函数

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 21:49:44 +08:00
Claude Opus c20e0ac298 chore: 优化代码质量和安全性\n\n- 删除未使用的 @aws-sdk/lib-storage 依赖,简化依赖\n- 修复重复导入 database 模块\n- 消除 formatSize 重复代码,提取为共享函数\n- 修复 verify.html XSS 漏洞,添加 HTML 转义\n- 更新 index.html 过时文案(断点续传→直连上传) 2026-01-18 20:23:39 +08:00
Claude OpusandClaude Opus 4.5 d39dd46f0c refactor: 优化代码重复和依赖导入
- ♻️ buildS3Config 复用 OssStorageClient.buildConfig (DRY 原则)
- ♻️ 删除重复的 crypto 导入(3处)
-  提高代码可维护性:配置逻辑只需在一处维护

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 18:27:43 +08:00
Claude OpusandClaude Opus 4.5 29e53bbcc7 refactor: 清理所有调试日志和死代码
- 🗑️ 删除未使用的依赖: net, dns 模块
- 🧹 将 console.log("[DEBUG]") 替换为 SystemLogDB.log()
- 🧹 清理分享验证相关的调试日志
- 🧹 清理数据库查询的调试日志
- 🧹 删除下载功能的调试日志
-  代码更专业,日志统一使用 SystemLogDB

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 18:09:18 +08:00
Claude OpusandClaude Opus 4.5 074a5bb98c fix: 清理 install.sh 中的所有 FTP/SFTP 残留代码
- 🐛 修复数据库路径检查(ftp-manager.db → data/database.db)
- 🐛 修复数据库迁移函数中的旧数据库路径
- 🐛 修复上传限制检查中的数据库路径
- 🐛 删除对不存在字段的引用
-  优化数据库迁移逻辑

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 17:44:45 +08:00
Claude OpusandClaude Opus 4.5 adcc1121a6 fix: 修复管理员OSS配置缺失和数据库迁移问题
- 🐛 修复管理员无法配置OSS的问题(添加管理员OSS配置界面)
- 🐛 修复 database.js 迁移代码引用不存在的 has_ftp_config 字段
-  管理员设置页面新增OSS配置、空间统计、存储切换功能
- 📝 完善管理员OSS配置体验

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 17:36:34 +08:00
Claude OpusandClaude Opus 4.5 358a3cac1c feat: v3.1.0 OSS直连优化与代码质量提升
- 🚀 OSS 直连上传下载(用户直连OSS,不经过后端)
-  新增 Presigned URL 签名接口
-  支持自定义 OSS endpoint 配置
- 🐛 修复 buildS3Config 不支持自定义 endpoint 的问题
- 🐛 清理残留的 basic-ftp 依赖
- ♻️ 更新 package.json 项目描述和版本号
- 📝 完善 README.md 更新日志和 CORS 配置说明
- 🔒 安全性增强:签名 URL 15分钟/1小时有效期

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 17:14:16 +08:00
19174616018 a7c577acef 更新 README:迁移到 Gitea 仓库
- 更新一键部署命令(curl/wget)
- 更新 Docker 部署的 git clone 命令
- 更新开发环境的 git clone 命令
- 更新项目地址,Gitea 为主仓库,Gitee 为镜像
- 更新问题反馈说明
2025-12-10 22:59:34 +08:00
19174616018andClaude e2c8bf2c23 🎨 调整文件列表hover颜色
- 暗色主题:rgba(255, 255, 255, 0.05) 微亮
- 亮色主题:rgba(0, 0, 0, 0.04) 微暗

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 16:35:45 +08:00
19174616018andClaude 300a17c5da 🐛 修复文件列表hover时变白的问题
- 移除硬编码的mouseover/mouseout颜色
- 添加.file-list-row类使用CSS处理hover效果
- 深色/浅色主题都使用半透明紫色作为hover背景

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 16:30:16 +08:00
19174616018andClaude 310553e0e3 🐛 修复文件名包含反引号时变成undefined的问题
- 从sanitizeInput正则表达式中移除反引号
- 之前map中没有反引号映射导致返回undefined

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 15:27:59 +08:00
19174616018andClaude 8d3962b67e 🐛 加强文件名解码的空值处理
- 后端decodeHtmlEntities添加空字符串默认值
- 前端decodeHtmlEntities非字符串时返回空字符串
- getFileDisplayName增强类型检查

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 14:56:26 +08:00
19174616018andClaude ac91fe7484 前端添加HTML实体解码兜底
- 添加decodeHtmlEntities方法解码文件名
- 添加getFileDisplayName统一获取显示名称
- 确保文件名正确显示,即使后端未解码

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 14:04:50 +08:00
19174616018andClaude 1221c0d5c9 添加displayName字段显示解码后的文件名
- 后端返回文件列表时添加displayName字段(解码HTML实体)
- 前端使用displayName显示文件名,保持原始name用于操作

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 13:54:58 +08:00
19174616018andClaude d111c63e8b 🐛 修复特殊字符文件名的处理问题
- 添加decodeHtmlEntities函数解码HTML实体
- 在rename/mkdir/folder-info/delete接口中解码文件名和路径
- 删除操作支持多候选路径,处理二次编码情况
- 移除sanitizeInput中对反引号的转义

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 13:44:23 +08:00
19174616018andClaude 0ba370dc83 🐛 优化弹窗被拦截时的处理方式
- 弹窗被阻止时改为toast提示,避免当前页跳转

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 13:12:41 +08:00
19174616018andClaude f4e76696d9 🐛 修复分享链接打开方式
- 添加openShare方法替代直接调用window.open
- 处理弹窗被浏览器阻止的情况,自动退回当前页跳转

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 13:04:11 +08:00
19174616018andClaude 00e1cadbed 重新设计分享管理页面
- 添加搜索/筛选功能(关键字、类型、状态、排序)
- 重新设计卡片视图,展示更多信息(状态、类型、加密、存储来源等)
- 添加filteredShares计算属性实现筛选和排序
- 添加辅助方法:getShareTypeLabel、getShareStatus、getShareProtection等
- 优化分享卡片样式,支持深色/浅色主题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 12:57:35 +08:00
19174616018andClaude b580ddecd7 优化监控页面加载体验
- 添加monitorTabLoading整体加载遮罩
- 创建openMonitorTab()和initMonitorTab()方法
- 使用Promise.all并行加载健康检测和系统日志
- 数据全部加载完成后才显示监控内容,彻底解决刷新闪烁问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 12:00:43 +08:00
19174616018andClaude c3ec191354 🐛 优化监控页面初始状态设置
- 将adminTab计算提取为initialAdminTab变量
- healthCheck.loading和systemLogs.loading根据initialAdminTab设置初始值
- 确保data()执行时所有状态一致,避免刷新闪烁

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 11:50:59 +08:00
19174616018andClaude aab7e1ff36 🐛 修复监控页面loading状态 - 在mounted中提前设置
- 将healthCheck.loading和systemLogs.loading初始值改回false
- 在mounted中、checkLoginStatus之前,判断adminTab并设置loading
- 确保在appReady变为true之前loading状态已正确设置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 11:28:18 +08:00
19174616018andClaude 1c49d34afb 🐛 修复监控页面刷新时的"没有数据"闪烁
- 健康检测:添加loading状态显示,使用v-else-if链
- 系统日志:修复v-if逻辑,loading优先于空状态显示
- 确保在数据加载完成前显示"加载中"而不是"暂无数据"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 11:24:07 +08:00
19174616018andClaude 6c1c07919f 🐛 修复监控页面刷新时显示"没有数据"的闪烁
- 当adminTab为monitor时,healthCheck和systemLogs的loading初始值设为true
- 这样在数据加载完成前显示"加载中"而不是"没有数据"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 11:19:19 +08:00
19174616018andClaude 3e68f57fc5 🐛 修复管理员标签页刷新时的UI闪烁
- adminTab初始值直接从localStorage读取,而非在checkLoginStatus中恢复
- 这样Vue初始化时就已经是正确的标签页,避免先显示概览再切换的闪烁

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 11:11:20 +08:00
19174616018andClaude c5b1d514be 🐛 修复页面刷新时UI闪烁问题
- 添加appReady状态控制应用显示时机
- 在checkLoginStatus完成后才显示主界面
- 添加加载占位符动画(云图标脉冲效果)
- 使用v-if/v-else确保UI状态一致性

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 11:07:07 +08:00
19174616018andClaude 07d8e41380 添加管理员标签页持久化功能
- 刷新页面后管理员标签页保持不变(概览/设置/监控/用户/工具)
- 使用localStorage存储当前标签页状态
- 登出时自动清理保存的标签页状态

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 11:03:55 +08:00
19174616018andClaude 34e874e1e6 ui: 优化监控页面交互体验
- 点击监控标签时自动加载健康检查和系统日志
- 缩小清理/刷新按钮尺寸,更简洁美观
- 按钮文字精简:"清理旧日志" → "清理"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 10:54:26 +08:00
19174616018andClaude c99b19a86d security: refreshToken 也存储在 HttpOnly Cookie 中
## 后端修改
- 登录时同时设置 token 和 refreshToken 的 HttpOnly Cookie
- refreshToken 有效期7天,token 有效期2小时
- 刷新接口优先从 Cookie 读取 refreshToken(向后兼容请求体)
- 登出时同时清除两个 Cookie

## 前端修改
- 移除 refreshToken 变量和相关逻辑
- 简化 doRefreshToken(),不再手动传递 refreshToken
- 简化 tryRefreshOrLogout(),直接尝试刷新

## 好处
- 页面刷新后 refreshToken 不会丢失
- 完全无感刷新,用户体验更好
- 前端代码更简洁(减少约20行)
- refreshToken 也无法被 XSS 窃取

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 10:38:40 +08:00
19174616018andClaude 38d18018a5 security: 实施 HttpOnly Cookie 鉴权方案
## 后端修改
- 新增 /api/logout 接口清除认证 Cookie

## 前端修改
- 移除 localStorage 存储 token/refreshToken(防止 XSS 窃取)
- 移除所有手动 Authorization 头(共36处)
- checkLoginStatus 改为直接调用 API 验证(Cookie 自动携带)
- logout 改为调用后端接口清除 Cookie
- 简化 token 刷新逻辑

## 安全性提升
- Token 从 localStorage 迁移到 HttpOnly Cookie
- XSS 攻击无法通过 JS 读取 token
- 配合 SameSite 属性防御 CSRF 攻击

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 10:33:27 +08:00
19174616018andClaude e81b1dd89e fix: 修复移动端文件列表滑动问题
- 移除 touchstart 中的 event.preventDefault(),不再阻止默认滚动
- 添加 handleLongPressMove 方法检测手指移动
- 滑动超过 10px 时自动取消长按,允许正常滚动
- 为 grid 和 list 视图的文件项添加 @touchmove 事件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 10:12:46 +08:00
19174616018andClaude ae27ee3791 fix: 修复验证码session保存时序问题
问题原因:
- 验证码API在session.save()完成前就发送响应
- 导致客户端获取验证码图片时session可能未保存成功

修复:
- 将res.send()移到session.save()回调内
- 确保session保存成功后再返回验证码图片
- 添加验证码验证调试日志帮助诊断

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 14:11:36 +08:00
19174616018andClaude a9cc3a56ad fix: 修复验证码请求429错误
问题:
- 短时间内多次请求验证码触发限流(429 Too Many Requests)

修复:
- 后端:验证码最小请求间隔从3秒改为1秒
- 前端:添加2秒防抖,避免重复请求
- 前端:429错误时保留已有验证码图片

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 14:06:23 +08:00
19174616018andClaude 06f60bec06 fix: 修复验证码session不一致的问题
问题原因:
- 验证码图片通过<img src>加载,可能不携带session cookie
- 导致验证码生成和表单提交使用不同的session

修复方案:
- 改用axios请求获取验证码(blob格式)
- 确保验证码请求携带withCredentials
- 点击"忘记密码"时立即加载验证码
- 切换到注册模式时立即加载验证码

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 13:50:06 +08:00
19174616018andClaude 6722d70666 feat: 实现Token刷新机制,缩短登录有效期
安全改进:
- Access Token有效期从7天缩短为2小时
- 添加Refresh Token机制(有效期7天)
- 关闭浏览器后较快失效,提升安全性

后端修改(auth.js):
- 添加generateRefreshToken函数生成刷新令牌
- 添加refreshAccessToken函数验证并刷新access token
- 分离ACCESS_TOKEN_EXPIRES和REFRESH_TOKEN_EXPIRES配置

后端修改(server.js):
- 登录时返回refreshToken和expiresIn
- 添加/api/refresh-token接口用于刷新token
- Cookie有效期同步调整为2小时

前端修改(app.js):
- 保存refreshToken到localStorage
- 添加自动刷新定时器(过期前5分钟刷新)
- 页面加载时若token过期自动尝试刷新
- 登出时清除refreshToken和定时器

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 13:46:51 +08:00
19174616018andClaude 8bcd097db6 feat: 注册、重置密码、重发验证邮件添加验证码功能
后端修改:
- 添加通用验证码验证函数 verifyCaptcha()
- /api/register 接口添加验证码验证
- /api/password/forgot 接口添加验证码验证
- /api/resend-verification 接口添加验证码验证

前端修改:
- 注册表单添加验证码输入框和图片
- 忘记密码模态框添加验证码
- 重发验证邮件区域添加验证码输入
- 添加各表单的验证码刷新方法
- 提交失败后自动刷新验证码

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 13:11:03 +08:00
19174616018andClaude 98d42b161c 新增独立的邮箱验证和重置密码页面
- 新建 verify.html: 邮箱验证专用页面,简洁美观
- 新建 reset-password.html: 重置密码专用页面,带表单验证
- 两个页面都支持亮色/暗色主题切换
- 更新 index.html 和 app.html 的重定向逻辑
- 旧链接自动重定向到新页面

用户体验改进:不再显示登录窗口背景,专注于当前操作

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 00:09:24 +08:00
19174616018andClaude 4e0a8cd370 首页和登录页支持全局主题
- index.html 添加亮色主题 CSS 变量和样式
- index.html 添加 JS 自动加载全局主题
- app.js 修改 initTheme,未登录时从公开 API 获取全局主题
- 登录页面现在会跟随管理员设置的全局主题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 00:00:53 +08:00
19174616018andClaude 38aeb18006 🔧 改进全局主题设置提示信息
当管理员设置了个人偏好时,提示"你设置了个人偏好,不受全局影响"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 23:49:06 +08:00
19174616018andClaude 73f2f021fd 🔧 添加全局主题设置调试日志
添加console.log帮助诊断全局主题设置不生效的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 23:45:31 +08:00
19174616018andClaude 1cb4d64d72 🐛 修复亮色主题下按钮不可见问题
- 添加 .btn-secondary 亮色主题样式(蓝色半透明背景)
- 添加 .btn-icon 亮色主题样式
- 修复管理员设置页面全局主题按钮显示问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 23:42:52 +08:00
19174616018andClaude f1b838f5e3 🐛 修复亮色主题导航栏文字颜色
- 添加 .nav-item 深色文字颜色
- 修复悬停和激活状态的颜色
- 添加 .user-info 深色文字
- 修复用户头像图标颜色

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 23:35:29 +08:00
19174616018andClaude f764604fd3 🐛 修复亮色主题下导航栏等组件的样式问题
- 添加亮色主题导航栏样式覆盖(白色半透明背景)
- 修复导航项悬停效果在亮色主题下的显示
- 修复用户信息区域的亮色主题样式
- 添加卡片、表格、模态框、输入框的亮色主题样式

这个修复解决了亮色主题下导航栏变黑、文字不可见的问题。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 23:33:19 +08:00
19174616018andClaude 376bf48bd1 🐛 修复主题偏好刷新后丢失的问题
原因:authMiddleware 中的 req.user 对象没有包含 theme_preference 字段,
导致 /api/user/theme 接口始终返回 undefined,用户设置的主题无法被正确读取。

修复:在 req.user 中添加 theme_preference 字段

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 23:25:25 +08:00
19174616018andClaude 07618abc06 🐛 修复暗色主题下存储管理区域的样式冲突
- 修复存储管理外层容器的白色渐变背景
- 修复本地存储和SFTP存储卡片的白色背景
- 修复上传进度条的白色背景
- 修复SFTP配置卡片的白色背景
- 统一使用CSS变量实现主题适配

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 23:14:27 +08:00
19174616018andClaude 121459f1b3 添加主题切换功能:支持暗色/亮色玻璃主题
功能说明:
- 管理员可在系统设置中配置全局默认主题
- 普通用户可在设置页面选择:跟随全局/暗色/亮色
- 分享页面自动继承分享者的主题偏好
- 主题设置实时保存,刷新后保持

技术实现:
- 后端:数据库添加theme_preference字段,新增主题API
- 前端:CSS变量实现主题切换,localStorage缓存避免闪烁
- 分享页:加载时获取分享者主题设置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 23:02:48 +08:00
19174616018andClaude f0802ee89e 🎨 完善暗色主题:修复右键菜单、分享页面等
- 修复右键菜单白色背景问题(改为暗色玻璃效果)
- 修复分享成功链接不可见问题(添加绿色高亮)
- 修复媒体预览器白色背景
- 完全重写share.html为暗色主题(与主应用风格统一)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 22:51:24 +08:00
19174616018andClaude 9be47f2009 🕐 修复系统日志时区问题
- 日志写入时使用 datetime('now', 'localtime') 替代 CURRENT_TIMESTAMP
- 日志统计和清理查询也使用本地时间
- 解决日志时间比实际时间慢8小时的问题(UTC → 北京时间)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 22:20:33 +08:00
19174616018andClaude b3fbd4c61f 🎨 将app.html全面改为暗色主题
- 添加CSS变量系统(背景色、边框色、文字颜色、强调色)
- 页面背景改为深色渐变效果
- 卡片、弹窗使用毛玻璃效果(backdrop-filter blur)
- 导航栏改为暗色玻璃样式
- 表单、按钮、表格全部适配暗色主题
- 添加自定义滚动条样式
- 统一警告/成功/错误提示框为半透明风格
- 全局替换浅色背景和文字颜色

与index.html保持一致的暗色毛玻璃设计风格

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 22:16:28 +08:00
19174616018andClaude c8d8643e07 feat(ui): 重新设计首页为暗色玻璃态风格
新设计特点:
- 暗色主题 + 动态渐变光斑背景
- 玻璃态卡片和导航栏效果
- 简洁两栏布局(文案+功能卡片)
- 网格背景增加科技感
- 固定底部技术栈展示
- 完整响应式适配

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 21:22:14 +08:00
19174616018andClaude 94f57b5b14 feat(install): 添加数据库自动迁移功能
更新和修复模式会自动检测并迁移旧数据库:
- 检测 backend/ftp-manager.db(旧路径)
- 自动迁移到 backend/data/database.db(新路径)
- 同时迁移 journal/wal/shm 文件
- 新旧数据库同时存在时警告用户

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 21:08:14 +08:00
19174616018andClaude aca6a44a50 fix(database): 读取环境变量DATABASE_PATH而非硬编码路径
之前硬编码为 ftp-manager.db,导致 .env 中的 DATABASE_PATH 无效。

修复:
- 读取 DATABASE_PATH 环境变量
- 默认路径改为 ./data/database.db
- 自动创建数据库目录
- 启动时打印数据库路径便于确认

⚠️ 升级注意:旧用户需要迁移数据库文件:
mv backend/ftp-manager.db backend/data/database.db

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 21:07:12 +08:00
19174616018 11058fe9b0 fix(admin): 调整角色列宽度防止换行 2025-11-27 20:52:31 +08:00
19174616018andClaude ad9b9999ed feat(admin): 用户列表添加角色列
- 新增"角色"列,显示"管理员"或"用户"标签
- 管理员显示紫色渐变背景+皇冠图标
- 普通用户显示灰色背景+用户图标
- 调整列宽适配新增列

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 20:47:47 +08:00
19174616018andClaude 6a0a8ac758 fix(security): 修复邮箱验证绕过漏洞
之前的迁移脚本会在每次服务启动时将所有 is_verified=0 的用户
自动设为已验证,导致攻击者可以用假邮箱注册后等待服务重启绕过验证。

修复方案:仅将 is_verified IS NULL 且 verification_token IS NULL 的
用户设为已验证(这些是邮箱验证功能上线前注册的老用户)。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 20:35:01 +08:00
19174616018andClaude 94662b8c07 feat(admin): 管理员页面改为标签页模式
- 添加5个标签页:概览、设置、监控、用户、工具
- 概览:调试模式开关、服务器存储统计
- 设置:系统设置、SMTP邮件配置
- 监控:健康检测、系统日志
- 用户:用户管理列表
- 工具:上传工具管理

优化管理员页面布局,减少滚动,提升使用体验

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 20:31:26 +08:00
19174616018andClaude c43181d983 fix(install): 修复更新/修复模式自动补充TRUST_PROXY配置
- 在 update_patch_env 中添加 TRUST_PROXY 检查和自动补充
- 修复模式(repair_main)现在也会调用 update_patch_env
- 更新 .env.example 添加 TRUST_PROXY 配置说明
- 更新修复模式的提示信息

解决使用Nginx反向代理时HTTPS检测失败的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 20:13:37 +08:00
19174616018andClaude e1943e4caa feat(admin): 添加系统日志功能
## 新功能

1. **系统日志数据库**
   - 新增 system_logs 表
   - 支持日志级别:debug/info/warn/error
   - 支持日志分类:auth/user/file/share/system/security
   - 记录用户ID、用户名、IP地址、User-Agent

2. **日志记录**
   - 用户注册成功/失败
   - 用户登录成功/失败(密码错误)
   - 系统操作(日志清理等)

3. **管理员API**
   - GET /api/admin/logs - 查询日志(支持分页和筛选)
   - GET /api/admin/logs/stats - 获取日志统计
   - POST /api/admin/logs/cleanup - 清理旧日志

4. **前端界面**
   - 日志列表展示(时间、级别、分类、内容、用户、IP)
   - 筛选功能(级别、分类、关键词搜索)
   - 分页导航
   - 清理旧日志功能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 19:54:46 +08:00
19174616018andClaude 4d49b3a7f8 fix(security): 修复信任代理和HTTPS检测的安全漏洞
## 问题修复

1. **trust proxy 配置安全加固**
   - 默认改为 false(不信任任何代理)
   - 支持多种安全配置:数字跳数、loopback、IP/CIDR段
   - 当配置为 true 时输出安全警告

2. **HTTPS 检测基于可信代理链**
   - 使用 req.secure 替代直接读取 x-forwarded-proto
   - Express 会根据 trust proxy 配置判断是否采信代理头
   - 防止客户端伪造协议头绕过 HTTPS 强制

3. **客户端 IP 获取安全加固**
   - 使用 req.ip 替代直接读取 X-Forwarded-For
   - Express 会根据 trust proxy 配置正确处理代理链
   - 防止客户端伪造 IP 绕过限流

4. **健康检测增加安全警告**
   - trust proxy = true 时标记为严重安全问题
   - 提示管理员配置更安全的代理信任策略

5. **安装脚本优化**
   - 默认配置 TRUST_PROXY=1(单层Nginx场景)
   - 添加详细的配置说明和安全警告

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 19:42:25 +08:00
19174616018andClaude 9c62bc6bf6 feat(admin): 添加系统健康检测功能
新增管理员健康检测面板,可检测以下配置项:
- 安全配置:JWT密钥、CORS、HTTPS、管理员账号、登录防爆破
- 服务状态:SMTP邮件、数据库连接、存储目录
- 运行配置:反向代理支持、Node环境

修改文件:
- backend/auth.js: 新增 isJwtSecretSecure() 函数
- backend/server.js: 新增 /api/admin/health-check API
- frontend/app.js: 新增健康检测数据和方法
- frontend/app.html: 新增健康检测UI界面

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 19:37:42 +08:00
19174616018andClaude 35d5e1b600 fix: SFTP下载使用新窗口打开直链
- app.js downloadFile: SFTP有直链时用window.open新窗口打开
- share.html downloadFile: 分享页面同样处理
- 本地存储下载保持原有方式不变
- 解决HTTPS页面下载HTTP直链的Mixed Content问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 15:24:20 +08:00
19174616018 b598c99773 Revert "fix: 修复HTTPS页面下载HTTP直链的Mixed Content问题"
This reverts commit ef476e23d2.
2025-11-27 15:20:11 +08:00
19174616018andClaude ef476e23d2 fix: 修复HTTPS页面下载HTTP直链的Mixed Content问题
- downloadFile: 检测Mixed Content自动降级到后端代理下载
- getMediaUrl: 媒体预览同样处理Mixed Content
- 当HTTPS页面遇到HTTP直链时,自动使用服务器代理
- 添加Toast提示用户正在使用代理下载

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 14:59:09 +08:00
19174616018andClaude ffc0a4e7a2 fix(security): 完善SSRF防护的IP地址检查
- 完整IPv6私有地址检查:fc00::/7, fe80::/10, ff00::/8
- 添加云服务元数据地址检查:169.254.0.0/16
- 添加运营商级NAT地址检查:100.64.0.0/10
- 支持IPv4映射的IPv6地址递归检查
- 使用正则表达式优化172.16.0.0/12检查
- 代码结构更清晰,按IPv4/IPv6分类处理

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 14:56:42 +08:00
19174616018 fadd1fc5d7 chore: 更新app.js版本号强制刷新缓存 2025-11-27 13:47:37 +08:00
19174616018andClaude 828aca897e fix: 修复token过期后仍显示已登录状态的问题
- 页面加载时先向服务器验证token是否有效
- 添加axios响应拦截器,任何API返回401时自动登出
- 新增handleTokenExpired方法统一处理token失效
- token失效时显示Toast提示用户重新登录

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 13:43:11 +08:00
19174616018andClaude 0bfb0a3cc9 feat: 添加SFTP空间使用统计功能
- 新增 /api/user/sftp-usage API,递归统计SFTP服务器空间使用情况
- 返回总使用空间、文件数、文件夹数
- 在设置页面显示SFTP空间统计信息
- 支持手动刷新统计数据
- 适配"仅SFTP"和"用户可选"两种权限模式的UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 13:39:51 +08:00
19174616018andClaude ba62b4e397 🔧 优化大文件上传支持
前端改进:
- 添加axios上传请求30分钟超时配置

Nginx改进:
- 增加代理超时时间到30分钟(1800秒)
- 添加 proxy_request_buffering off 避免大文件缓存到磁盘
- 添加 proxy_buffering off 禁用响应缓冲
- 优化 client_body_buffer_size

修复 ERR_HTTP2_PING_FAILED / Network Error 问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 12:24:49 +08:00
19174616018andClaude bfe6cf05c4 🐛 修复邮箱验证链接无效的问题
- 修复 UserDB.create() 中 verification_token 未哈希存储的bug
- 注册时的token现在会进行SHA256哈希,与验证时的逻辑保持一致
- 解决"无效或已过期的验证链接"错误

问题原因:注册时存储原文token,验证时用哈希后的token匹配,导致永远匹配不上

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 12:21:11 +08:00
19174616018andClaude c3c6198a2f 🗑️ 移除不需要的文件
- 删除 docker-compose.yml
- 删除 restore-data.sh

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 11:50:04 +08:00
19174616018andClaude d8f2220613 🐛 修复本地存储文件删除失败的问题
问题:sanitizeInput 函数将 / 转义为 &#x2F;,导致文件路径错误
修复:从 XSS 过滤中移除对 / 的转义,因为它是路径分隔符的合法字符

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 11:44:51 +08:00
19174616018andClaude 871efc8382 🔒 修复分享列表接口的目录遍历漏洞
- 在 /api/share/:code/list 增加路径规范化与范围校验
- 使用 path.posix.normalize 处理 subPath,阻断 ../ 遍历攻击
- 调用 isPathWithinShare 验证请求路径在分享范围内
- 统一使用安全的 requestedPath 进行存储访问和直链生成

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 11:10:48 +08:00
19174616018andClaude 7312798884 优化上传体验:上传前检查文件大小限制
问题:原来文件上传完成后才提示超过大小限制,浪费用户时间

修复:
- 后端:添加 /api/config 公开接口返回上传大小限制
- 前端:页面加载时获取配置
- 前端:uploadFile 函数开始时检查文件大小,超限立即提示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:46:10 +08:00
19174616018andClaude b99c5e1e26 🎨 优化管理员系统设置页面布局
在"最大上传大小"设置旁边添加保存按钮,使其更明显
添加提示:修改后需要重启服务才能生效

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:39:05 +08:00
19174616018andClaude 91cdc72318 🐛 修复页面刷新时短暂显示混乱UI的问题
使用 Vue 的 v-cloak 指令,在 Vue 初始化完成前隐藏页面内容,
避免用户看到原始模板标记(FOUC问题)

- app.html: 添加 v-cloak
- share.html: 添加 v-cloak

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:30:50 +08:00
19174616018andClaude 510550a8d5 🎨 修复通知和按钮显示问题
1. Toast通知改为只显示最新一条,避免多条同时出现
2. 修复首页"已有账号"按钮在渐变背景上看不清的问题
   - 使用白色边框和文字
   - 添加半透明背景和模糊效果
   - 移动端也能清晰显示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:27:14 +08:00
19174616018andClaude 48d40f7cde 🐛 修复管理员存储统计磁盘容量显示为0的问题
- 将存储目录路径从 local-storage 改为 storage,与 storage.js 保持一致
- 添加目录不存在时自动创建的逻辑
- 支持 STORAGE_ROOT 环境变量配置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 09:57:06 +08:00
19174616018andClaude d515f3f922 🔒 安全加固:修复多个中高危漏洞
修复内容:
1. Host Header 注入 - 添加 PUBLIC_BASE_URL 和 ALLOWED_HOSTS 白名单
2. API密钥暴力破解 - 添加速率限制(5次/小时,封锁24小时)
3. 路径遍历漏洞 - 增强路径验证,防止空字节注入和目录遍历
4. 令牌安全 - 密码重置和邮箱验证令牌使用SHA256哈希存储
5. 文件上传安全 - 阻止PHP/JSP/ASP等可执行脚本上传
6. IDOR防护 - 增强权限验证和安全日志
7. XSS防护 - 增强输入过滤,阻止javascript:等危险协议
8. 日志脱敏 - 移除验证码等敏感信息的日志输出
9. CSRF增强 - HTTPS环境使用strict模式Cookie
10. 邮箱枚举防护 - 密码重置统一返回消息
11. 速率限制 - 文件列表(60次/分)和上传(100次/小时)

配置说明:
- PUBLIC_BASE_URL: 必须配置,用于生成安全的邮件链接
- ALLOWED_HOSTS: 可选,Host头白名单
- COOKIE_SECURE=true: 生产环境必须开启

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 09:48:46 +08:00
19174616018andClaude 94112e5453 🔒 修复中高危安全漏洞并增强文件安全验证
**关键安全修复:**
1. 修复弱随机数生成器(中危)
   - 分享码生成从Math.random()改为crypto.randomBytes()
   - 防止分享链接被预测或暴力猜测

2. 增强文件上传安全验证(中危)
   - 新增isSafePathSegment()函数验证文件名
   - 禁止路径遍历字符(..、/、\、控制字符)
   - 添加上传路径规范化和安全检查

**功能改进:**
- 管理员界面显示用户邮箱验证状态
- 优化用户状态展示(已封禁/未激活/正常)

**安全影响:**
- 消除分享链接可预测性风险
- 防止文件上传路径遍历攻击
- 提升文件系统访问控制安全性

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 01:37:02 +08:00
19174616018andClaude ea2f5aede8 🔒 修复命令注入漏洞并增强HTTPS配置
**安全修复:**
- 修复命令执行语法错误(wmic命令字符串拼接)
- 添加驱动器字母验证防止命令注入(仅允许A-Z)
- 修复命令执行参数构造错误

**功能增强:**
- 新增ENFORCE_HTTPS环境变量(强制HTTPS访问)
- 更新.env.example添加ENFORCE_HTTPS配置说明
- 更新install.sh支持自动配置ENFORCE_HTTPS
- 更新脚本自动为现有.env补充ENFORCE_HTTPS配置

这些改进消除了命令注入风险并提供了更灵活的HTTPS策略控制。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 01:17:32 +08:00
19174616018andClaude ef9965c632 🔒 增强安全防护:新增多层输入验证和XSS防护
- 添加用户名正则验证(支持中英文、数字、下划线等)
- 新增HTTPS强制访问选项(通过环境变量控制)
- 实现HTML转义函数防止邮件XSS注入
- 增强HTTP直链URL校验,仅允许http/https协议
- 添加buildHttpDownloadUrl安全构建下载URL
- 优化密码重置流程,增加账号状态验证
- 全面应用sanitizeHttpBaseUrl确保URL安全

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 00:52:56 +08:00
19174616018andClaude 563026d4e8 新增视图记忆功能,优化用户体验
功能新增:
- 自动记住用户上次停留的视图(文件/分享/设置/管理)
- 下次登录时恢复到上次的视图位置
- 登出时清理视图记录

安全优化:
- 新增 isViewAllowed() 方法,验证视图权限
- 防止普通用户越权访问管理后台
- 只记录合法且有权限的视图

代码优化:
- 简化登录后的视图跳转逻辑
- switchView() 支持 force 参数,用于强制刷新
- 统一视图切换和权限检查流程

用户体验提升:
- 减少重复导航,直达上次工作位置
- 管理员可以记住后台页面位置
- 更加智能和人性化

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 22:09:40 +08:00
19174616018andClaude 8b5885675a 🧹 清理冗余文档和脚本文件
项目清理:
- 删除重复的部署文档(DEPLOY.md, DEPLOYMENT.md, DOCKER部署指南.md等)
- 删除过时的验证码功能文档(CAPTCHA_*.md)
- 删除临时修复脚本(fix_*.sh)
- 删除旧版本文档(VERSION.txt, UPDATE_SUMMARY_v1.1.0.md)
- 删除重复的部署脚本(deploy.sh)

精简原因:
- README.md 已包含完整的部署和使用说明
- install.sh 提供一键部署功能
- 减少文档冗余,降低维护成本
- 保持项目结构清晰简洁

保留的核心文件:
- README.md(主文档)
- install.sh(一键部署脚本)
- docker-compose.yml(Docker 配置)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 21:38:56 +08:00
19174616018andClaude 6a40a6a6f5 🎨 简化存储管理界面,移除重复按钮
前端优化:
- 移除顶部的快速切换按钮
- 只保留下方详细的卡片式按钮
- 保留视觉指示器(进度条动画)
- 减少界面冗余,提升简洁性

视觉改进:
- 界面更加简洁清爽
- 避免重复的操作按钮
- 用户操作更加明确

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 21:26:31 +08:00
19174616018andClaude 4d1e0766cf 💄 优化存储管理卡片布局对齐
前端优化:
- 使用 flexbox 布局确保卡片等高
- 按钮自动对齐到卡片底部
- 添加 align-items: stretch 确保卡片高度一致
- 使用 margin-top: auto 将按钮区域推到底部

视觉改进:
- 两个存储卡片高度保持一致
- 操作按钮在同一水平线上对齐
- 整体布局更加美观和协调

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 21:11:48 +08:00
19174616018andClaude e132fa1d2d 🐛 修复用户信息同步时机问题
前端修复:
- 在检测存储配置更新前,先同步完整的用户信息
- 确保 has_ftp_config 等字段及时更新
- 使用对象扩展运算符合并用户信息,避免覆盖现有字段

问题修复:
- 修复存储配置检测可能使用过期用户信息的问题
- 确保用户配置状态与服务器保持同步

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 21:07:08 +08:00
19174616018andClaude 2861b652db 💄 优化存储配置提示逻辑
前端优化:
- 新增 profileInitialized 标志,避免首次加载时显示误导性提示
- 新增 suppressStorageToast 标志,用户主动切换后不显示配置同步提示
- 首次加载时只同步存储配置,不弹出提示
- 用户主动切换存储方式后,下一次配置同步不提示"管理员已修改"

用户体验改进:
- 避免用户刚登录就看到"管理员已更改存储方式"的误导提示
- 区分用户主动操作和管理员修改操作
- 减少不必要的提示干扰

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 20:59:20 +08:00
19174616018andClaude 1ff5198bdf 💄 优化文件下载体验
前端优化:
- 统一文件下载逻辑,HTTP 和 SFTP 下载使用相同方式
- 移除 window.open 弹出新窗口的方式
- 统一使用隐藏的 <a> 标签触发下载
- 简化代码逻辑,提高可维护性

用户体验改进:
- 避免浏览器弹出窗口拦截
- 下载过程更加流畅
- 减少额外的页面跳转

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 20:51:14 +08:00
19174616018andClaude 7f91f3afd5 🐛 修复邮件验证链接兼容性问题
前端优化:
- 新增 getTokenFromUrl() 方法,支持解析不规范的 URL 参数
- 新增 sanitizeUrlToken() 方法,统一清理 URL 中的 token
- 兼容旧版邮件链接格式(缺少 ? 的情况)
- 优化 URL 参数提取逻辑,使用正则表达式兼容多种格式
- 改进代码复用性,减少重复代码

问题修复:
- 修复某些邮件客户端生成的链接无法正常解析 token 的问题
- 确保验证链接和重置链接在各种环境下都能正常工作

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 20:24:24 +08:00
19174616018andClaude 9447ef8eea 🔒 增强安全防护:防止 SSRF 攻击和 Token 泄露
后端安全增强:
- 新增 SSRF 防护机制,验证 SFTP 目标地址
- 添加 isPrivateIp() 函数,检测并阻止连接内网地址
- 添加 validateSftpDestination() 函数,验证主机名和端口
- 支持 DNS 解析和 IP 地址验证
- 添加 SFTP 连接超时配置(默认8秒)
- 移除 URL 参数中的 token 认证,只接受 Header 或 HttpOnly Cookie

前端安全改进:
- 移除下载链接中的 token 参数
- 改为依赖同域 Cookie 进行身份验证
- 避免 token 在 URL 中暴露,防止日志泄露

环境变量配置:
- ALLOW_PRIVATE_SFTP=true 可允许连接内网(测试环境)
- SFTP_CONNECT_TIMEOUT 可配置连接超时时间

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 20:07:34 +08:00
19174616018andClaude 2a1f13c335 优化 SFTP 配置流程和界面
前端优化:
- 重新设计 SFTP 配置引导弹窗,采用渐变色头部
- 新增独立的 SFTP 配置弹窗,支持导入 .inf 配置文件
- 移除 forceSftpConfigVisible 标志,改用弹窗方式
- 简化存储切换逻辑,提升用户体验
- 优化配置表单布局和提示文字

交互改进:
- 支持拖拽导入配置文件
- 优化配置流程,更加直观
- 改进错误提示和操作引导

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 19:30:00 +08:00
19174616018andClaude af9d2f48ce 📝 重写 README 文档 & 优化前端功能
文档更新:
- 完全重写 README.md,更加详细和专业
- 添加一键部署命令(curl 和 wget 两种方式)
- 详细介绍双存储模式、邮件系统、安全防护等特性
- 添加完整的使用指南和常见问题解答
- 更新项目结构和技术栈说明
- 添加版本更新日志

前端优化:
- 优化存储管理界面交互
- 改进文件管理功能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 19:21:39 +08:00
19174616018andClaude 82ddb0ab27 🔥 移除旧密码重置审核系统 & 优化存储管理UI
后端改进:
- 移除需要管理员审核的密码重置请求功能
- 简化密码重置流程,直接使用邮件重置
- 删除 password_reset_requests 表及相关代码

前端优化:
- 重新设计存储管理界面,采用现代化渐变风格
- 改进存储方式切换交互,添加动画效果
- 优化视觉层次和信息展示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 19:12:57 +08:00
19174616018andClaude 5a190127fc 优化邮件验证和密码重置链接
- 修改邮件中的验证和重置链接,直接指向 app.html 页面
- 在落地页添加容错脚本,自动重定向带 token 的请求到应用页
- 提升用户体验,减少跳转步骤

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 18:57:50 +08:00
19174616018andClaude 50e714b2dd 🐛 修复邮箱验证和密码重置的时间戳问题
后端修复:
- 修改时间戳存储格式:从 ISO 字符串改为数值时间戳(毫秒)
- 优化时间戳比较逻辑:兼容新旧格式的时间戳
- 修复 VerificationDB.consumeVerificationToken() 的时间比较
- 修复 PasswordResetTokenDB.use() 的时间比较
- 统一使用 Date.now() 生成时间戳

前端改进:
- 新增 verifyMessage 独立显示验证相关提示
- 优化邮箱验证成功后的用户体验(自动切换到登录表单)
- 优化密码重置成功后的用户体验(自动切换到登录表单)
- 改进提示信息显示方式

技术细节:
- SQLite 时间比较:strftime('%s','now')*1000 获取当前毫秒时间戳
- 兼容旧数据:同时支持字符串和数值时间戳比较

这个修复解决了邮箱验证令牌一直提示过期的问题。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 15:48:32 +08:00
19174616018andClaude 58e6bf01cc 🔧 优化install.sh的Nginx处理逻辑
改进 restart_nginx_safe():
- systemctl 改用 reload 代替 restart,避免不必要的服务中断
- 优先使用 reload,如未运行才使用 start
- 添加端口占用检测提示

改进 ensure_nginx_installed():
- 不再强制自动安装 Nginx
- 改为友好提示:支持用户使用已有反向代理(如宝塔/1Panel等)
- 允许用户选择手动安装或跳过

增强宝塔面板支持:
- 修复模式支持读取宝塔 Nginx 配置路径
- 支持备份宝塔配置文件
- 清理宝塔配置备份文件

改进服务检测:
- Nginx 检测兼容多种运行方式(systemctl/进程/宝塔)
- 更准确的进程检测逻辑

这些优化使脚本更灵活,兼容更多部署场景,减少端口冲突问题。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 15:39:28 +08:00
19174616018andClaude 40fe89bfb7 增强install.sh对宝塔面板的支持
新增功能:
- 添加 ensure_nginx_installed() 函数,自动检测并安装 Nginx
- 在所有 Nginx 配置函数中调用确保 Nginx 已安装
- restart_nginx_safe() 新增宝塔面板路径支持(/www/server/nginx/sbin/nginx)

改进点:
- 优先尝试宝塔面板的 Nginx 路径
- 支持在更新/修复模式下自动安装缺失的 Nginx
- 根据不同包管理器自动选择安装方式(apt/yum/dnf/zypper)
- 增强错误提示和安装验证

兼容性:
- 完全兼容宝塔面板环境
- 兼容标准 Linux 系统环境
- 支持多种发行版的包管理器

这些改进使脚本能在更多环境下自动化部署,提升用户体验。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 15:09:48 +08:00
19174616018andClaude 9d353b3f77 🔧 改进install.sh的Nginx重启机制
新增功能:
- 添加 restart_nginx_safe() 函数,提供安全的Nginx重启/重载机制
- 支持多种重启方式的自动回退(systemctl → nginx -s reload → nginx直接启动)
- 添加配置测试验证(nginx -t)确保配置正确后再重启
- 改进进程检测,兼容无systemd环境

改进点:
- 替换所有直接的 systemctl restart/reload 为安全重启函数
- 增强错误处理和用户提示
- 提升脚本在不同系统环境下的兼容性
- 防止错误配置导致Nginx服务中断

这些改进使安装脚本更加健壮,减少部署失败的风险。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 14:46:22 +08:00
19174616018andClaude f5cb6a6adf 🐛 修复邮件发送功能的小问题
- 修复发件人字段处理,避免空字符串导致发送失败
- 改进SMTP测试错误消息显示,支持详细错误信息
- 更新package-lock.json锁定nodemailer版本

这些修复提升了邮件功能的稳定性和错误提示的友好性。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 14:37:34 +08:00
19174616018andClaude e442d1983b ✉️ 添加完整的邮件系统功能
后端新增功能:
- 集成 nodemailer 支持 SMTP 邮件发送
- 新增邮箱验证系统(VerificationDB)
- 新增密码重置令牌系统(PasswordResetTokenDB)
- 实现邮件发送限流(30分钟3次,全天10次)
- 添加 SMTP 配置管理接口
- 支持邮箱激活和密码重置邮件发送

前端新增功能:
- 注册时邮箱必填并需验证
- 邮箱验证激活流程
- 重发激活邮件功能
- 基于邮箱的密码重置流程(替代管理员审核)
- 管理后台 SMTP 配置界面
- SMTP 测试邮件发送功能

安全改进:
- 邮件发送防刷限流保护
- 验证令牌随机生成(48字节)
- 重置链接有效期限制
- 支持 SSL/TLS 加密传输

支持的邮箱服务:QQ邮箱、163邮箱、企业邮箱等主流SMTP服务

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 14:28:35 +08:00
19174616018andClaude bd00b30940 🚀 增强验证码安全性和防刷机制
- 添加验证码请求限流器(30次/10分钟,超限封锁30分钟)
- 添加验证码请求间隔控制(最小3秒间隔)
- 升级验证码复杂度:4位数字 → 6位字母数字混合
- 移除易混淆字符(I/l/O/0/1等)
- 优化验证码显示参数(尺寸、干扰线等)

这些改进大幅提升了验证码的安全性,有效防止暴力破解和恶意刷新。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 10:25:10 +08:00
19174616018andClaude 5026c4a9e1 🔧 改进反向代理和Session安全配置
- 添加trust proxy配置,支持在Nginx/Cloudflare后正确识别客户端IP和协议
- 优化Session cookie配置,HTTPS环境下使用sameSite=none以支持跨域
- 移除测试脚本test_captcha.sh

这些改进确保系统在反向代理环境下正常工作,并提升了安全性。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 10:03:57 +08:00
19174616018andClaude 64e466873e 🔧 修复install.sh中的shell变量转义问题
- 修复3处Nginx配置中的Cookie变量转义($http_cookie → \$http_cookie)
- 移除多余的文档文件(交接文档、更新说明)
- 清理文件末尾空行

这个修复确保install.sh脚本能正确生成Nginx配置,而不会将$http_cookie误解释为shell变量。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 09:52:31 +08:00
19174616018 fb00045f1e 📋 添加完整交接文档 - 验证码功能 2025-11-21 17:08:33 +00:00
19174616018 73840c76a2 📖 添加验证码快速修复指南 2025-11-21 17:05:22 +00:00
19174616018andClaude 6a3741dc36 🔧 更新install.sh - 添加Nginx Cookie传递配置
## 修改说明
install.sh生成的Nginx配置中缺少Cookie传递设置,
导致验证码session无法正常工作。

## 修改内容

### install.sh
在3处 `location /api` 配置中添加Cookie传递:

```nginx
# Cookie传递配置(验证码session需要)
proxy_set_header Cookie $http_cookie;
proxy_pass_header Set-Cookie;
```

修改位置:
- 行2383: HTTP配置(configure_nginx_http_first)
- 行2665: HTTPS配置(configure_nginx_final)
- 行2802: 虚拟主机配置

### 新增文件

1. **fix_install_sh.sh**
   - 自动化修改脚本
   - 备份原文件后自动添加Cookie配置
   - 可重复运行(检测已修改则跳过)

2. **INSTALL_SH_UPDATE.md**
   - 详细的修改说明文档
   - 手动修改方法
   - 验证和应用指南

## 使用方法

### 新部署
直接运行修改后的install.sh即可

### 自动修改
```bash
./fix_install_sh.sh
```

### 已部署服务
需要手动更新Nginx配置:
```bash
vim /etc/nginx/conf.d/玩玩云.conf
# 在 location /api 块中添加Cookie配置
nginx -t
nginx -s reload
```

## 配套修复
此修改配合以下提交才能完整工作:
- 后端session配置 (28bd000)
- 前端axios配置 (c7e5bb5)
- nginx/nginx.conf (0c4a3ff)
- install.sh (本提交)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 16:54:26 +00:00
19174616018andClaude 0c4a3fff37 🐛 修复Nginx Cookie传递问题 - 验证码Session关键修复
## 问题根源(终于找到了!)
Nginx反向代理默认不会正确传递Cookie,导致:
1. 后端设置的 Set-Cookie 响应头被Nginx丢弃
2. 前端发送的 Cookie 请求头无法到达后端
3. 验证码session完全无法工作

## 症状
- 验证码一直提示"已过期"
- 即使前端配置了 withCredentials: true 也无效
- 浏览器看不到 captcha.sid cookie

## 修复方案

在 nginx.conf 的 /api/ location 块中添加:

```nginx
# Cookie传递配置(验证码session需要)
proxy_set_header Cookie $http_cookie;
proxy_pass_header Set-Cookie;
```

### 配置说明

1. `proxy_set_header Cookie $http_cookie;`
   - 将浏览器发送的Cookie转发给后端
   - 后端可以读取验证码session

2. `proxy_pass_header Set-Cookie;`
   - 将后端的Set-Cookie响应头传递给浏览器
   - 浏览器可以保存验证码session cookie

## 完整的验证码工作流程

1. **验证码生成**:
   ```
   浏览器 → Nginx → 后端生成验证码和session
   后端 → Set-Cookie: captcha.sid=xxx → Nginx → 浏览器保存
   ```

2. **验证码验证**:
   ```
   浏览器(带Cookie) → Nginx → 后端读取session验证
   ```

## 部署说明

### Docker环境
```bash
docker-compose down
docker-compose up -d
```

### 手动Nginx
```bash
nginx -t  # 测试配置
nginx -s reload  # 重新加载
```

## 验证方法

1. 清除浏览器Cookie
2. F12 → Network → 清除日志
3. 访问登录页,输错密码2次
4. 查看 /api/captcha 响应头应该有 Set-Cookie
5. 查看 /api/login 请求头应该有 Cookie: captcha.sid=xxx
6. 输入验证码应该能正常通过

## 相关提交

此修复配合以下提交才能完整工作:
- 后端session配置修复 (28bd000)
- 前端axios withCredentials配置 (c7e5bb5)
- Nginx Cookie传递配置 (本提交)

三个修复缺一不可!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 16:49:38 +00:00
19174616018andClaude 10903b175a 🔄 更新前端版本号 - 强制刷新浏览器缓存
## 问题
浏览器缓存了旧版本的app.js,新的axios配置无法生效

## 修改
app.js?v=20251110001 → app.js?v=20251121001

## 说明
版本号更新后,浏览器会自动下载新的app.js文件,
包含最新的axios withCredentials配置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 16:46:58 +00:00
19174616018andClaude c7e5bb5a3b 🐛 修复验证码跨域Cookie传递问题
## 问题描述
验证码在生产环境(https://cs.workyai.cn)一直提示"验证码已过期"

## 根本原因
axios默认不携带credentials(包括cookies),导致:
1. 验证码生成时的session cookie无法被浏览器保存
2. 登录时无法读取到验证码session
3. SessionID不一致导致验证失败

## 修复方案
在mounted钩子中添加axios全局配置:
```javascript
axios.defaults.withCredentials = true;
```

这样所有axios请求都会携带cookies,包括:
- 验证码生成请求
- 登录验证请求
- 所有其他API请求

## 配合后端配置
后端已配置:
- CORS: credentials: true
- Session cookie: sameSite: 'lax'
- Session: saveUninitialized: true

## 测试说明
1. 清除浏览器Cookie
2. 访问 https://cs.workyai.cn
3. 输错密码2次触发验证码
4. 输入验证码应该能正常通过

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 16:42:37 +00:00
19174616018andClaude 28bd0007f9 🐛 修复验证码Session问题 - 验证码过期Bug修复
## 问题描述
用户输入验证码后一直提示"验证码已过期"

## 根本原因
Session配置问题导致验证码无法正确保存和读取:
1. saveUninitialized: false 导致验证码请求时session不会被创建
2. 缺少 sameSite 属性导致某些情况下cookie无法正确传递

## 修复方案

### Session配置优化
- saveUninitialized: false → true (确保验证码请求时创建session)
- 添加 name: 'captcha.sid' (自定义session cookie名称)
- 添加 sameSite: 'lax' (防止CSRF同时确保同站请求携带cookie)

### 验证码生成API增强
- 添加 req.session.save() 确保session立即保存
- 添加调试日志输出SessionID和验证码内容

### 登录API调试
- 添加详细的验证码验证日志
- 输出SessionID、失败次数、验证码匹配情况
- 帮助快速定位问题

## 测试建议
1. 清除浏览器Cookie
2. 输错密码2次触发验证码
3. 查看后端日志确认SessionID一致
4. 输入正确验证码应该能通过验证

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 16:39:38 +00:00
19174616018andClaude 96eed34366 添加登录验证码功能 - 增强系统安全性
## 新增功能
- 密码输错2次后自动显示验证码
- 4位数字验证码,点击可刷新
- 验证码有效期5分钟
- 基于IP和用户名双重防护
- 前台和后台登录均支持

## 后端改动
- 新增验证码生成API: GET /api/captcha
- 修改登录API支持验证码验证
- 添加session管理验证码
- 增强RateLimiter防爆破机制

## 前端改动
- 登录表单添加验证码输入框(条件显示)
- 验证码图片展示和刷新功能
- 自动触发验证码显示逻辑

## 依赖更新
- 新增: svg-captcha (验证码生成)
- 新增: express-session (session管理)

## 文档
- CAPTCHA_FEATURE.md - 详细功能文档
- CAPTCHA_README.md - 快速开始指南
- test_captcha.sh - 自动化测试脚本
- 更新说明_验证码功能.txt - 中文说明

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 16:32:32 +00:00
19174616018 6d02af66bf 🐛 修复分享页下载文件时页面闪动问题
- 问题:使用window.open()下载文件会导致页面短暂失焦,产生闪动效果
- 修复:改用隐藏的<a>标签触发下载,避免打开新标签页
- 新增triggerDownload()方法处理文件下载
- 影响文件:frontend/share.html (line 744-795)
2025-11-18 18:10:03 +08:00
19174616018 c9179437cd 🐛 修复文件夹分享bug - 只显示第一个文件的问题
问题:
- 分享文件夹时,分享链接只显示文件夹内第一个文件
- 原因:前端无论文件还是文件夹都使用share_type='file'
- 后端对非file类型的分享路径处理不正确

修复方案:
1. 前端修改(frontend/app.js):
   - shareFileForm添加isDirectory字段标记是否为文件夹
   - openShareFileModal设置isDirectory值
   - createShareFile根据isDirectory设置正确的share_type
     * 文件: share_type='file'
     * 文件夹: share_type='directory'

2. 后端修改(backend/database.js):
   - 修复ShareDB.create中的sharePath逻辑
   - file类型:使用file_path(单文件路径)
   - directory类型:使用file_path(文件夹路径)
   - all类型:使用'/'(根目录)

测试:
- 分享单个文件:正常显示单个文件
- 分享文件夹:正常显示文件夹内所有文件
- 分享所有文件:正常显示根目录所有文件
2025-11-18 17:58:31 +08:00
19174616018 f935fefc54 🔒 修复CORS逻辑 - 正确处理同源请求
问题:
- 第一次修复过于严格,拒绝了所有无Origin头的请求
- 导致浏览器的同源请求被拒绝,网站完全无法使用

修复方案:
- 允许无Origin头的请求(同源请求不触发CORS)
- 严格验证带Origin头的跨域请求(必须在白名单中)
- 拒绝所有未授权的跨域请求

文件修改:
- backend/server.js: 修正CORS中间件逻辑(第48-63行)

测试:
- 同源请求正常工作
- 恶意跨域请求被拒绝
- API返回正确的状态码(不再是500错误)
2025-11-18 17:08:22 +08:00
19174616018andClaude 56ba6edf3c 🔒 修复CORS严重安全漏洞 & 增强Nginx安全配置
## 关键修复

### 1. 修复backend/server.js的CORS漏洞 ⚠️ CRITICAL
**问题**: 原代码 `if (!origin || allowedOrigins.includes(origin))`
        会允许所有没有Origin头的请求通过,导致恶意请求绕过CORS保护

**修复**: 严格白名单模式
```javascript
// 只允许白名单中的域名
if (origin && allowedOrigins.includes(origin)) {
  callback(null, true);
} else {
  // 拒绝所有其他请求
  callback(new Error('CORS策略不允许来自该来源的访问'));
}
```

**影响**:
-  阻止所有恶意域名的跨域访问
-  保护JWT token不被窃取
-  从63.6%预计提升到90%+安全评分

### 2. 增强install.sh中的Nginx安全配置

在所有三个nginx配置函数中添加了完整的安全规则:
- `configure_nginx_http_first()` - 初始HTTP配置
- `configure_nginx_http()` - 纯HTTP模式
- `configure_nginx_https()` - HTTPS模式

**新增安全配置**:
```nginx
# 安全响应头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # HTTPS专用

# 隐藏Nginx版本
server_tokens off;

# 禁止访问隐藏文件 (.git, .env等)
location ~ /\. {
    deny all;
    return 404;
}

# 禁止访问敏感文件
location ~ \.(env|git|config|key|pem|crt|sql|bak|backup|old|log)$ {
    deny all;
    return 404;
}
```

**防护效果**:
-  阻止访问 /.env, /.git/config
-  阻止访问备份文件 .bak, .sql
-  防止点击劫持、XSS、MIME嗅探攻击
-  强制HTTPS(HTTPS环境下)
-  隐藏服务器信息

## 部署方法

在服务器上执行:
```bash
cd /var/www/wanwanyun
git pull origin master
bash install.sh --repair  # 重新生成Nginx配置
# 或者手动
pm2 restart wanwanyun-backend
nginx -t && systemctl reload nginx
```

然后运行安全测试验证:
```bash
node security-test.js
```

预期改进:
- CORS测试: 只有cs.workyai.cn被允许 
- 敏感文件: 全部返回404 
- 安全响应头: 全部检测到 
- 安全评分: 63.6% → 90%+ 🎯

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 16:58:19 +08:00
19174616018andClaude c81266ae0e 🛡️ 添加更新时的安全配置自动检查和修复
解决了使用install.sh更新时,旧的不安全CORS配置仍然保留的问题。

## 新增功能

### 自动安全配置检查
更新时会自动检查 backend/.env 中的安全配置:

1. **CORS配置检查**
   - 检测 ALLOWED_ORIGINS=* (不安全)
   - 自动从Nginx配置读取域名
   - 自动检测HTTP/HTTPS协议
   - 提示用户确认并自动修复

2. **环境变量检查**
   - 检查NODE_ENV是否设置为production
   - 给出安全建议

### 工作流程

当执行 `bash install.sh --update` 时:

```bash
检查安全配置...
⚠️  检测到不安全的CORS配置: ALLOWED_ORIGINS=*

这是一个严重的安全风险!攻击者可以从任何域名访问你的API。

检测到域名: cs.workyai.cn
建议将CORS设置为: https://cs.workyai.cn

是否自动修复CORS配置?[y/n]:
```

### 修复机制

-  自动备份原配置到 .env.backup.YYYYMMDD_HHMMSS
-  智能检测HTTP/HTTPS
-  从Nginx配置读取域名
-  用户确认后自动修改
-  如果无法自动修复,给出手动修复指引

### 兼容性

-  不影响安全的现有配置
-  只修复明确不安全的配置(ALLOWED_ORIGINS=*)
-  提供备份,可随时回滚

## 使用方法

```bash
# 更新项目(会自动检查安全配置)
bash install.sh --update

# 或者使用交互式菜单
bash install.sh
# 选择 [2] 更新/升级
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 16:37:39 +08:00
19174616018andClaude dc6d801aeb 🔒 重大安全更新:修复CORS、XSS、敏感文件暴露等漏洞
本次更新修复了安全测试中发现的所有严重问题,大幅提升系统安全性。

## 修复的安全问题

### 1. CORS跨域配置漏洞 ⚠️ 严重
**问题**: 默认允许所有域名访问(ALLOWED_ORIGINS=*)
**修复**:
- 默认值改为空数组,生产环境必须明确配置域名白名单
- 未配置时拒绝所有跨域请求(生产环境)
- 开发环境仅允许localhost访问

### 2. XSS跨站脚本攻击 ⚠️ 严重
**问题**: 用户输入未过滤,可注入恶意脚本
**修复**:
- 添加XSS过滤中间件,自动转义所有POST/PUT请求的用户输入
- 过滤 <, >, ', " 等危险字符
- 递归处理嵌套对象和数组

### 3. 缺少安全响应头 ⚠️ 重要
**问题**: 缺少X-Frame-Options等安全响应头
**修复**:
- X-Frame-Options: SAMEORIGIN (防止点击劫持)
- X-Content-Type-Options: nosniff (防止MIME嗅探)
- X-XSS-Protection: 1; mode=block
- Strict-Transport-Security (HTTPS环境)
- Content-Security-Policy (内容安全策略)
- 隐藏X-Powered-By和Server版本信息

### 4. 敏感文件暴露风险 ⚠️ 严重
**问题**: .env、.git等敏感文件可能被访问
**修复**:
- Nginx配置禁止访问以.开头的隐藏文件
- 禁止访问.env、.git、.config、.key、.pem等敏感文件
- 更新.gitignore,防止敏感文件提交到代码仓库
- 添加证书、密钥等文件类型到忽略列表

## 代码改动

### backend/server.js
- 修改CORS默认配置,移除危险的 * 通配符
- 添加安全响应头中间件
- 添加XSS过滤中间件(sanitizeInput函数)
- 生产环境强制检查ALLOWED_ORIGINS配置

### nginx/nginx.conf
- 添加安全响应头配置
- 禁止访问隐藏文件和敏感文件的location规则
- 隐藏Nginx版本号(server_tokens off)

### .gitignore
- 添加敏感配置文件保护(.env.local, config.json等)
- 添加证书和密钥文件类型(.key, .pem, .crt等)

### deploy.sh
- 修改默认配置,移除ALLOWED_ORIGINS=*
- 添加安全警告提示

## 部署说明

⚠️ **重要**: 更新后必须配置ALLOWED_ORIGINS环境变量!

### 手动部署
编辑 `backend/.env` 文件:
```bash
ALLOWED_ORIGINS=https://cs.workyai.cn
NODE_ENV=production
```

### 使用install.sh部署
脚本会自动根据域名配置ALLOWED_ORIGINS

## 测试结果

修复前安全评分: 57.6% (14个安全问题)
修复后预期评分: 90%+ (预计解决12+个问题)

## 兼容性

-  向后兼容,不影响现有功能
-  开发环境自动允许localhost访问
- ⚠️ 生产环境必须配置ALLOWED_ORIGINS(否则无法访问)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 16:29:44 +08:00
19174616018andClaude 9f7abd498e 优化检测逻辑,减少误判
修复了正常访问时也提示"检测到开发者工具"的问题。

核心改进:
1. 提高检测阈值,避免误判
   - 窗口尺寸差异从160px提高到200px
   - 要求console和其他方法同时检测到才触发
2. 降低检测频率
   - 从每1秒改为每2秒检测一次
   - 减少性能消耗和误判概率
3. 严格判定条件
   - console检测为主要依据
   - debugger和窗口尺寸作为辅助确认

检测逻辑:
- consoleOpen && (debuggerPause || windowSizeAbnormal)
- 必须console明确检测到,且至少有一个辅助条件满足

说明:
前端防护无法完全阻止技术人员查看源代码(Network面板、
右键查看源代码等),但可以有效防止普通用户使用F12。
真正的安全依赖于后端验证和权限控制。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 21:01:57 +08:00
19174616018andClaude f2899038b0 终极防护:在页面渲染前检测开发者工具
解决了用户打开控制台后能看到源代码的问题。

核心改进:
1. 将检测代码移到<head>最前面,在页面渲染前执行
2. 使用document.write()立即阻止页面加载
3. 检测到开发者工具时throw Error阻止后续脚本
4. 删除底部重复的检测代码,统一在头部处理
5. 优化检测逻辑,使用console.log触发toString

工作原理:
- 页面加载第一时间就执行检测
- 如果检测到开发者工具,立即用document.write替换页面
- throw Error阻止后续所有脚本和Vue应用加载
- 用户无法看到真实页面内容和源代码

测试场景:
 先打开F12再访问 → 立即显示警告,页面不加载
 访问后按F12 → 快捷键被禁用
 调试模式 → 所有保护自动禁用

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 20:40:13 +08:00
19174616018andClaude d0868e6cbf 重构开发者工具检测机制 - 使用多重检测方法
修复了控制台在其他页面打开后仍能在本站显示的问题。

核心改进:
1. 新增Console对象toString检测(最有效)
2. 优化debugger暂停时间检测
3. 保留窗口尺寸检测作为补充
4. 综合多种方法,提高检测准确率
5. 页面加载时立即执行检测
6. 持续监控确保实时防护

检测原理:
- console.log会触发对象的toString方法
- 当控制台打开时,这个方法会被调用
- 通过标志位检测到调用,即可判断控制台已打开

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 20:33:01 +08:00
19174616018andClaude e24a897120 增强开发者工具检测防护功能
修复了用户可以在其他页面先打开F12控制台,然后访问网站时绕过禁用的问题。

主要改进:
- 页面加载时立即检测开发者工具是否已打开
- 新增 debugger 断点检测机制,检测更灵敏
- 检测到开发者工具时显示美观的全屏警告页面
- 防止重复警告显示
- 调试模式下所有保护机制自动禁用

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 19:57:02 +08:00
WanWanYunandClaude 7f2050cc4f fix: 修复文件夹详情功能点击无反应的问题
问题描述:
- 右键文件夹 → 点击"查看详情"
- 菜单关闭但没有任何反应
- 详情弹窗不显示

根本原因:
在 contextMenuAction(action) 的 switch 语句中
**缺少 case 'info': 分支**

代码分析:
```javascript
contextMenuAction(action) {
  switch (action) {
    case 'preview': ...
    case 'download': ...
    case 'rename': ...
    case 'share': ...      //  直接跳到share,跳过了info
    case 'delete': ...
  }
}
```

修复方案:
在 rename 和 share 之间添加:
```javascript
case 'info':
  this.showFolderInfo(this.contextMenuFile);
  break;
```

修复后流程:
1. 右键文件夹 → "查看详情"
2. 触发 contextMenuAction('info')
3. 执行 showFolderInfo(file)
4. 调用后端 API: POST /api/files/folder-info
5. 显示详情弹窗(大小、文件数、子文件夹数)

之前为什么漏掉:
添加showFolderInfo方法时,忘记在contextMenuAction中注册该action

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 00:22:40 +08:00
WanWanYunandClaude be49163157 fix: 修复更新时无法选择是否保留上传工具的问题
问题描述:
- 显示了选择菜单,但read命令直接跳过
- 无法输入选择,直接使用默认值1
- 用户想选择2也无法输入

原因分析:
- update流程中标准输入可能被重定向或被其他命令消耗
- read命令从stdin读取时可能读到空输入
- 导致直接使用默认值

解决方案:
使用 `< /dev/tty` 强制从终端读取:
```bash
read -p "▶ 请选择 [1/2, 默认:1]: " KEEP_UPLOAD_TOOL < /dev/tty
```

工作原理:
- /dev/tty: 当前终端设备
- < /dev/tty: 强制从终端读取输入
- 即使stdin被重定向,仍能从终端获取用户输入

测试建议:
运行 bash install.sh update 时:
1. 显示选择菜单
2. 等待用户输入
3. 输入1: 保留工具
4. 输入2: 重新下载
5. 直接回车: 使用默认值1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 00:21:22 +08:00
WanWanYunandClaude 1b59c8b021 fix: 修复更新脚本选择"不保留上传工具"无法触发重新下载的问题
问题描述:
- 更新时选择"2) 删除并重新下载"
- 但实际上只是删除并复制了新的upload-tool目录
- 没有触发后续的build_upload_tool()重新下载
- 导致用户无法获取最新的上传工具

原因分析:
- else分支(选择2)只做了 rm + cp 操作
- 复制的新目录中没有dist/玩玩云上传工具.exe
- 但后续检查逻辑看的是文件是否存在和大小
- 如果目录结构存在,即使文件不存在也不会重新下载

修复方案:
在else分支最后添加:
```bash
# 删除dist目录以触发后续重新下载
rm -rf "${PROJECT_DIR}/upload-tool/dist"
```

修复后逻辑:
1. 选择"1"保留:
   - 备份dist目录
   - 更新upload-tool脚本文件
   - 恢复dist目录
   - 跳过后续下载

2. 选择"2"重新下载:
   - 删除整个upload-tool
   - 复制新的upload-tool
   - **删除dist目录**  NEW
   - 触发后续build_upload_tool()重新下载

验证:
后续update_install_dependencies后面的检查:
```bash
if [[ ! -f "${PROJECT_DIR}/upload-tool/dist/玩玩云上传工具.exe" ]]; then
    build_upload_tool  #  会被触发
fi
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 00:19:13 +08:00
WanWanYunandClaude 70ea2ec7fc fix: 修复文件夹详情功能 + 添加文件夹分享
修复内容:

1. 【文件夹详情无反应】
   - 问题: folder-info API中变量名冲突
   - 原因: API参数名'path'与Node.js的'path'模块冲突
   - 具体: 第1193行 `const itemPath = path.join(dirPath, item.name)`
            这里的path被当作API参数(字符串)而不是模块
   - 修复:
     * API参数改为 `const { path: dirPath, folderName }`
     * 使用dirPath替代path
     * countFiles函数参数改为countDirPath避免混淆
   - 效果: 查看详情功能现在正常工作

新功能:

2. 【文件夹分享】
   - 移除分享功能的文件限制
   - 右键菜单"分享"选项对文件和文件夹都显示
   - 文件夹分享后可访问该文件夹下所有文件
   - 与现有分享API完全兼容(share_type支持file和all)

技术细节:
- backend/server.js:
  * 第1138行: path参数改为dirPath
  * 第1186行: countFiles函数参数改为countDirPath
  * 第1193行: 使用path.join正确引用模块

- frontend/app.html:
  * 移除 `v-if="!contextMenuFile.isDirectory"` 限制
  * 文件夹也显示"分享"菜单项

使用方式:
1. 右键文件夹 → "查看详情" → 显示大小、文件数、子文件夹数
2. 右键文件夹 → "分享" → 分享整个文件夹

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 00:15:43 +08:00
WanWanYunandClaude 1f732c8e87 feat: 优化更新流程 - 添加保留上传工具选项
问题背景:
- 之前每次更新都会删除并重新下载上传工具(43MB)
- 重新下载耗时长(1-2分钟),且可能因网络问题失败
- 但如果不删除,万一上传工具有更新也无法获取

优化方案:
在更新流程中添加用户选择:

更新上传工具逻辑:
1. 检测是否已存在上传工具可执行文件
2. 验证文件大小(>30MB为有效)
3. 如果存在有效工具,显示选择菜单:

   ╔════════════════════════════════════════════════════════════╗
   ║                   上传工具更新选项                         ║
   ╠════════════════════════════════════════════════════════════╣
   ║  1) 保留现有上传工具(推荐,节省下载时间)                ║
   ║  2) 删除并重新下载(如果工具有更新)                      ║
   ╚════════════════════════════════════════════════════════════╝

选项1 - 保留现有工具:
- 创建临时目录备份dist/玩玩云上传工具.exe
- 更新upload-tool目录的脚本文件
- 恢复备份的可执行文件
- 优点: 节省1-2分钟下载时间,避免网络问题

选项2 - 重新下载:
- 删除整个upload-tool目录
- 在后续步骤重新下载最新工具
- 适用: 官方发布了新版本上传工具

默认行为:
- 默认选项1(保留),直接回车即可
- 如果现有文件大小异常(<30MB),自动重新下载
- 如果不存在上传工具,直接复制新目录

技术改动:
- install.sh update_pull_latest_code() 函数
- 添加用户交互选择逻辑
- 添加文件大小验证
- 添加备份和恢复逻辑

使用建议:
- 日常更新: 选择"1"保留,节省时间
- 重大版本更新: 选择"2"重新下载,确保最新

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 00:11:17 +08:00
WanWanYunandClaude bab5047b5a fix: 修复文件夹右键菜单问题 + 新增文件夹详情功能
修复内容:

1. 【文件夹右键菜单无法显示】
   - 问题: showFileContextMenu() 中仍有 `if (file.isDirectory) return` 阻止代码
   - 原因: 之前的修复脚本替换失败,代码仍在
   - 修复: 手动移除这行限制代码
   - 效果: 文件夹现在可以正常右键操作(重命名、删除、查看详情)

新功能:

2. 【文件夹详情查看】
   后端API (POST /api/files/folder-info):
   - 接收参数: path(当前路径), folderName(文件夹名称)
   - 计算文件夹总大小(递归统计所有文件)
   - 统计文件数量和子文件夹数量
   - 返回数据: name, path, size, fileCount, folderCount
   - 仅支持本地存储

   前端功能:
   - 右键菜单新增"查看详情"选项(仅文件夹显示)
   - 详情弹窗显示:
     * 文件夹名称
     * 文件夹路径
     * 总大小(格式化显示)
     * 文件数量
     * 子文件夹数量
   - 加载中状态提示
   - 错误处理和提示

使用方式:
1. 右键点击任意文件夹
2. 选择"查看详情"
3. 弹窗显示文件夹统计信息

技术改动:
- backend/server.js: +100行 (新增folder-info API)
- frontend/app.html: +40行 (详情弹窗UI + 菜单项)
- frontend/app.js: +30行 (状态 + showFolderInfo方法)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 00:07:21 +08:00
WanWanYunandClaude 398ac45301 fix: 修复文件夹功能的多个严重bug
问题修复:

1. 【文件夹无法重命名和删除】
   - 原因: showFileContextMenu 函数中 `if (file.isDirectory) return` 阻止了文件夹显示右键菜单
   - 修复: 移除这个限制,允许文件夹显示右键菜单
   - 效果: 文件夹现在可以右键重命名和删除

2. 【进入文件夹后无法返回上一页】
   - 原因: 页面没有路径导航条(面包屑导航)
   - 修复: 在文件列表上方添加完整的路径导航组件
   - 功能:
     * 显示当前路径层级
     * 点击路径中的任意层级可快速跳转
     * "返回上一级"按钮
     * "返回根目录"按钮
   - 新增 navigateUp() 方法处理返回上一级

3. 【上传文件到子文件夹路径错误(undefined文件夹)】
   - 原因: loadFiles() 调用时未传递 path 参数,导致 currentPath 变为 undefined
   - 修复:
     * loadFiles 开头添加安全检查: `this.currentPath = path || '/'`
     * createFolder 成功后调用 loadFiles 时传递 currentPath
   - 效果: 路径不会变成undefined,文件正确上传到当前目录

4. 【优化删除确认提示】
   - 旧提示: "只能删除空文件夹"(错误信息)
   - 新提示: "⚠️ 警告:文件夹内所有文件将被永久删除!"(准确描述行为)

技术改动:
- frontend/app.html: +15行 (路径导航条UI)
- frontend/app.js: +10行 (navigateUp方法 + 安全检查)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 23:59:08 +08:00
WanWanYunandClaude b138e3fba7 fix: 修复createFolder方法未正确添加到Vue methods中的问题
问题: 点击"新建文件夹"按钮时控制台报错 "createFolder is not a function"
原因: 之前使用正则表达式添加方法时没有成功匹配,导致方法未被添加
修复: 手动在renameFile方法后面正确添加createFolder方法

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 23:50:27 +08:00
WanWanYunandClaude 71cbaa88d1 feat: 新增本地存储文件夹管理功能
新功能概述:
- 支持在本地存储模式下创建文件夹
- 支持删除文件夹(递归删除)
- 支持重命名文件夹(已有功能,天然支持)
- 文件夹配额计算正确

后端改动:

1. 新增创建文件夹API (backend/server.js)
   - POST /api/files/mkdir
   - 参数: path(当前路径), folderName(文件夹名称)
   - 安全检查: 禁止特殊字符(/ \ .. :),防止路径遍历攻击
   - 仅限本地存储使用
   - 创建前检查文件夹是否已存在

2. 改进删除功能 (backend/storage.js)
   - LocalStorageClient.delete() 现在支持删除文件夹
   - 使用 fs.rmSync(path, { recursive: true }) 递归删除
   - 新增 calculateFolderSize() 方法计算文件夹总大小
   - 删除文件夹时正确更新配额使用情况

前端改动:

1. 新建文件夹按钮 (frontend/app.html)
   - 在"上传文件"按钮旁新增"新建文件夹"按钮
   - 仅本地存储模式显示

2. 新建文件夹弹窗 (frontend/app.html)
   - 简洁的创建表单
   - 支持回车键快速创建
   - 使用优化的弹窗关闭逻辑(防止拖动选择文本时误关闭)

3. 前端API调用 (frontend/app.js)
   - 新增 createFolderForm 状态
   - 新增 createFolder() 方法
   - 前端参数验证
   - 创建成功后自动刷新文件列表

4. 右键菜单优化 (frontend/app.html)
   - 文件夹不显示"下载"按钮(文件夹暂不支持打包下载)
   - 文件夹不显示"分享"按钮(分享单个文件夹暂不支持)
   - 文件夹支持"重命名"和"删除"操作

安全性:
- 文件夹名称严格验证,禁止包含 / \ .. : 等特殊字符
- 路径安全检查,防止目录遍历攻击
- 仅限本地存储模式使用(SFTP存储使用上传工具管理)

配额管理:
- 空文件夹不占用配额
- 删除文件夹时正确释放配额(计算所有子文件大小)
- 删除非空文件夹会递归删除所有内容

使用方式:
1. 登录后切换到本地存储模式
2. 点击"新建文件夹"按钮
3. 输入文件夹名称,点击创建
4. 双击文件夹进入,支持多级目录
5. 右键文件夹可重命名或删除

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 23:46:20 +08:00
WanWanYunandClaude 5a27f0b7e9 fix: 修复弹窗交互问题和拖放上传UI重复显示
问题描述:
1. 弹窗点击外部关闭时,选择文本拖动鼠标到窗口外会误触关闭
2. 拖放文件上传的提示UI重复显示了两次

修复内容:

【弹窗交互优化】
- 将所有模态框的 @click 事件改为 @mousedown.self + @mouseup.self 组合
- 新增 handleModalMouseDown 和 handleModalMouseUp 方法
- 只有在同一个overlay元素上按下和释放鼠标时才关闭弹窗
- 解决了选择文本时拖动鼠标导致弹窗意外关闭的问题
- 受影响的弹窗:
  * 重命名文件弹窗
  * 分享所有文件弹窗
  * 分享单个文件弹窗
  * 忘记密码弹窗
  * 文件审查弹窗
  * 编辑存储权限弹窗

【拖放UI修复】
- 删除了重复的拖放上传提示层(原第670-677行)
- 保留文件列表区域内的拖放提示层(第711-718行)
- 现在拖放上传提示只显示一次

技术改进:
- mousedown.self: 只在overlay本身被按下时触发
- mouseup.self: 只在overlay本身被释放时触发
- 记录mousedown的目标,只有目标一致才执行关闭
- 提升了用户体验,避免误操作

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 22:32:50 +08:00
WanWanYunandClaude ca92b7ed94 fix: 修复宝塔面板环境下install.sh安装脚本Nginx重载失败导致脚本闪退的问题
问题描述:
- 在宝塔面板环境下运行install.sh时,Nginx重载步骤会因为命令失败导致整个脚本闪退
- 由于脚本使用了 set -e,任何命令返回非0退出码都会导致脚本终止

修复内容:
1. configure_nginx_http_first 函数 (2408-2446行):
   - 移除可能失败的 /etc/init.d/bt restart 命令
   - 在所有Nginx命令前添加配置测试
   - 使用 || true 防止命令失败导致脚本退出
   - 优化错误提示,失败时不退出脚本

2. configure_nginx_final 函数 (2504-2538行):
   - 同样的修复逻辑应用到Nginx重载环节
   - 确保即使Nginx操作失败,脚本也能继续执行后续步骤

技术改进:
- 先测试Nginx配置再reload,避免配置错误导致服务中断
- reload失败时自动尝试重启Nginx
- 所有可能失败的命令都添加了错误处理
- 保留systemctl作为备用方案

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 19:31:52 +08:00
19174616018 b2e6c7fe7c 删除文件 test_rate_limit.sh 2025-11-14 17:28:12 +00:00
19174616018 0521687f89 删除文件 test_share_security.sh 2025-11-14 17:28:05 +00:00
19174616018andClaude a41a29d581 fix: 分享详情页显示到期时间
问题:
- 分享列表中显示的到期时间正确
- 但点进分享详情页时,到期时间只显示"永久有效"

原因:
- /api/share/:code/verify 接口返回的 share 对象中缺少 expires_at 字段
- 前端 shareInfo.expires_at 为 undefined,导致始终显示"永久有效"

修复:
- 在 server.js:1650 添加 expires_at 到响应数据中
- 现在分享详情页能正确显示到期时间

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 20:59:48 +08:00
19174616018andClaude f02bcee646 fix: 彻底修复所有表格和列表的文字溢出问题
关键修复:
1. 所有表格添加 table-layout: fixed
2. 表格列头添加明确的宽度百分比控制
3. 文件列表 - 修复 display: flex 导致的溢出
   - 将 flex 从 td 移到内部 div
   - 文件名使用 flex: 1 + min-width: 0
   - 所有图标添加 flex-shrink: 0
4. 分享列表 - 移除 max-width,使用列头宽度控制
5. 用户列表 - 移除 max-width,使用列头宽度控制

所有表格现在都能正确处理长文本,不会溢出容器

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 20:30:22 +08:00
19174616018andClaude d99568e418 fix: 修复前端所有页面的文字溢出问题
修复内容:
1. 文件列表 - 列表视图文件名溢出控制
2. 用户列表 - 用户名和邮箱溢出控制
3. 分享列表 - 文件路径和分享链接溢出控制

所有长文本现在都会正确显示省略号,不会导致UI布局异常

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 19:47:36 +08:00
19174616018andClaude f9d0a30d46 fix: 修复移动端分享列表长文件名导致UI溢出问题
## 问题
在移动端"我的分享"的列表视图下,长文件名会导致:
- 表格宽度被撑开
- 分享链接等内容溢出到容器外
- 页面布局错乱

## 修复
为分享列表的表格添加文字溢出处理:

1. 文件路径列:
   - 限制max-width: 200px
   - 超出部分显示省略号(text-overflow: ellipsis)
   - 鼠标悬停显示完整路径(title属性)

2. 分享链接列:
   - 限制max-width: 250px
   - 超出部分显示省略号
   - 鼠标悬停显示完整链接

## 其他位置检查
- 大图标视图已有溢出处理(-webkit-line-clamp: 2),无需修改
- 文件列表视图也已有完善的溢出处理

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 17:12:15 +08:00
19174616018andClaude 8cda4c1a0b fix: 修复时区问题 - 使用本地时区存储和比较时间
## 问题
- 之前使用UTC时间存储expires_at
- 前端解析时当成本地时间,导致显示时间差8小时
- 例如: 数据库存 08:37 (UTC),前端显示 08:37 (本地),实际应该显示 16:37

## 修复
1. database.js create方法: 使用本地时区格式化时间
   - 使用 getFullYear/getMonth/getDate 等获取本地时间
   - 格式化为 YYYY-MM-DD HH:MM:SS (本地时间)

2. database.js findByCode方法: 使用本地时区比较
   - 改为 datetime('now', 'localtime')
   - 确保与存储的本地时间格式匹配

3. 调试日志也使用本地时区

## 效果
- 创建分享时,expires_at存储的是服务器本地时间
- 前端显示时间与服务器时间一致
- 过期判断使用本地时区,结果正确

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 16:46:26 +08:00
19174616018andClaude d56388dd29 fix: 修复分享过期时间格式导致的过滤失败问题
## 问题分析
通过调试日志发现,过期分享仍能访问的根本原因是:
- expires_at 存储格式: `2025-11-14T07:31:31.922Z` (ISO 8601)
- datetime('now') 返回格式: `2025-11-14 08:19:52` (SQLite格式)
- SQLite进行字符串比较时: 'T' (ASCII 84) > ' ' (ASCII 32)
- 导致条件 `expires_at > datetime('now')` 对已过期分享仍返回true

## 修复内容
1. database.js: 修改create方法,将expires_at转换为SQLite datetime格式
   - 旧格式: 2025-11-14T07:31:31.922Z
   - 新格式: 2025-11-14 07:31:31

2. fix_expires_at_format.js: 数据库修复脚本
   - 将已存在的ISO格式时间转换为SQLite格式
   - 确保历史数据也能正确过滤

## 部署步骤
```bash
cd /var/www/wanwanyun
git pull
cd backend
node fix_expires_at_format.js  # 修复历史数据
pm2 restart wanwanyun-backend   # 重启服务
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 16:25:31 +08:00
19174616018andClaude eaf9ad7bb1 debug: 添加详细的分享验证调试日志
## 调试内容
为了排查过期分享仍能访问的问题,添加了详细的调试日志:

### 1. database.js - ShareDB.findByCode()
- 记录调用时的参数和SQLite当前时间
- 记录SQL查询结果(是否找到、过期时间、分享类型)
- 便于对比JavaScript时间和SQLite时间

### 2. server.js - /api/share/:code/verify
- 记录请求时间戳、分享码、是否有密码、请求IP
- 记录findByCode的返回结果和过期状态

### 3. server.js - /api/share/:code/list
- 记录请求时间戳、分享码、子路径、密码状态
- 记录findByCode的返回结果和过期状态

## 使用方法
1. 管理员在管理后台开启调试模式
2. 访问分享链接 https://cs.workyai.cn/s/oSrhV9D3
3. 查看后端console日志输出

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 16:09:01 +08:00
19174616018andClaude bce225ec5c feat: 添加管理员可控的F12调试模式开关
## 功能说明
- 管理员登录后可在管理后台页面看到"调试模式"卡片
- 点击开关按钮可启用/禁用F12和开发者工具
- 调试模式状态保存在localStorage,页面刷新后保持
- 同时控制主应用(app.html)和分享页面(share.html)的调试权限

## 技术实现
1. app.js新增debugMode配置和toggleDebugMode方法
2. app.html添加调试模式开关UI,并修改防调试代码支持debugMode控制
3. share.html添加防调试代码,受localStorage中的debugMode控制

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 16:04:17 +08:00
19174616018andClaude 024e807f75 fix: 优化时间显示逻辑,支持分钟和小时级别的到期提醒
问题: 0.01天(约14分钟)显示为"今天过期",不够精确
原因: 使用Math.ceil向上取整,导致小于1天的时间都显示为1天

修复内容:
1. 改用Math.floor向下取整,更准确反映剩余时间
2. 新增diffMinutes和diffHours变量,支持分钟和小时级别显示
3. 优化判断逻辑:
   - 0-59分钟: 显示"X分钟后过期"
   - 1-23小时: 显示"X小时后过期"
   - 1天: 显示"明天过期"
   - 2-7天: 显示"X天后过期"
   - 7天以上: 显示具体日期时间

修改文件:
- frontend/app.js: formatExpireTime方法(第1317-1335行)
- frontend/share.html: formatExpireTime方法(第836-854行)

测试示例:
- 0.01天(14分钟) → "14分钟后过期 (2025-11-14 12:27)"
- 0.5天(12小时) → "12小时后过期 (2025-11-15 00:00)"
- 1天 → "明天过期 (2025-11-15 12:13)"
- 7天 → "7天后过期 (2025-11-21 12:13)"

关于过期文件清理:
已分析当前机制,过期分享无法访问但记录不会自动删除。
详细分析和解决方案见《分享过期处理机制分析报告.md》。
当前实现是安全的,自动清理功能可作为后续优化。

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 15:22:19 +08:00
19174616018andClaude 84e9a19f3e fix: 修复分享到期时间显示的两个问题
问题1: 分享成功提示中到期时间显示为"永久有效"
- 修复: backend/database.js 的create方法添加返回expires_at字段
- 修复: backend/server.js 的API响应中添加expires_at字段
- 现在创建分享后会正确显示设置的到期时间

问题2: share.html分享页面不显示到期时间
- 新增: 在分享信息中添加到期时间显示(frontend/share.html:527-528)
- 新增: 添加formatExpireTime/isExpiringSoon/isExpired方法
- 效果: 访问分享页面时可以看到"到期时间"或"永久有效"

改进:
- 使用颜色区分状态(绿色=永久/正常, 黄色=即将过期, 红色=已过期)
- 友好的时间显示格式(X天后过期/明天过期等)

注意: 关于0.01天显示问题的优化方案已文档化,需要手动修改app.js

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 13:54:38 +08:00
19174616018andClaude 692b495005 feat: 添加分享到期时间显示功能
新增功能:
- 在分享列表表格中添加"到期时间"列,清晰显示每个分享的有效期
- 在分享创建成功后显示到期时间信息
- 添加友好的时间格式化显示(永久有效/今天过期/明天过期/X天后过期)
- 使用颜色区分不同状态:
  * 绿色: 永久有效
  * 蓝色: 正常有效期
  * 黄色: 即将过期(3天内)
  * 红色: 已过期

技术实现:
- frontend/app.html: 添加到期时间显示UI(表格列+分享结果)
- frontend/app.js: 添加3个辅助方法
  * formatExpireTime(): 格式化到期时间显示
  * isExpiringSoon(): 判断是否即将过期(3天内)
  * isExpired(): 判断是否已过期

改进用户体验:
- 用户可以直观看到分享何时过期
- 即将过期的分享会有醒目的黄色提醒
- 鼠标悬停显示完整的ISO时间戳
- 时间显示本地化,使用中文格式

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 12:23:41 +08:00
19174616018 70b03c3c6b fix: 修复分享页面文件名过长导致布局溢出的问题
修改内容:
1. 单文件显示 (.single-file-name):
   - 添加 max-width: 100% 限制最大宽度
   - 使用 -webkit-line-clamp: 3 限制最多显示3行
   - 超出部分显示省略号 (...)
   - 改用 word-break: break-word 更优雅的换行

2. 列表视图文件名:
   - 添加 .file-name-container 和 .file-name-text 类
   - 使用 overflow: hidden 和 text-overflow: ellipsis
   - 单行显示,超出显示省略号
   - 设置 min-width: 0 确保flex容器正确缩小

修复效果:
- 文件名再长也不会溢出到界面外
- 单文件视图最多显示3行
- 列表视图单行显示带省略号
- 鼠标悬停在网格视图文件名上会显示完整文件名(title属性)
2025-11-14 09:20:13 +08:00
19174616018 cd79496fc4 fix: 修复分享页面时间显示问题,正确显示本地时间
- 修改 share.html 中的 formatDate 函数
- 与 app.js 保持一致的 UTC 时间处理逻辑
- 使用 toLocaleString 格式化为本地时间
- 修复分享页面显示 13号(UTC) 而不是 14号(CST) 的问题
2025-11-14 01:08:47 +08:00
19174616018 61344756e9 fix: 修复前端时间显示问题,正确处理UTC时间并转换为本地时间(北京时间)
- 修改 formatDate 函数,识别 SQLite 返回的 UTC 时间格式
- 自动将 UTC 时间字符串转换为 ISO 格式并添加 'Z' 标记
- JavaScript Date 对象会自动将 UTC 时间转换为本地时间显示
- 修复之前显示 13号(UTC) 而不是 14号(CST) 的问题
2025-11-14 00:51:39 +08:00
19174616018 927eab2102 test: 添加分享安全性测试脚本
功能:
- 测试分享过期时间检查
- 测试分享密码防爆破保护(list接口)
- 提供手动测试指引

使用方法:
  ./test_share_security.sh
2025-11-14 00:16:05 +08:00
19174616018 4879d4891f fix: 修复分享过期时间和防爆破保护覆盖不全的安全问题
问题1 - 分享过期时间未强制校验:
- 在ShareDB.findByCode()中添加过期时间检查
- SQL条件: AND (s.expires_at IS NULL OR s.expires_at > datetime('now'))
- 现在过期的分享链接将返回404,无法访问

问题2 - 分享密码防爆破保护覆盖不全:
- 给/api/share/:code/list添加shareRateLimitMiddleware
- 给/api/share/:code/download-file添加shareRateLimitMiddleware
- 在两个接口的密码验证失败时调用recordFailure
- 在两个接口的密码验证成功时调用recordSuccess
- 防止攻击者绕过/verify接口直接暴力破解

影响:
- 分享过期后将无法访问(安全性提升)
- 所有分享密码验证接口都受到限流保护(10次/10分钟)
- 修复了可绕过防爆破保护的安全漏洞
2025-11-14 00:15:15 +08:00
19174616018 53be6dc145 test: 添加防爆破保护测试脚本
使用方法:
  ./test_rate_limit.sh

功能:
- 自动测试登录防爆破保护
- 连续5次错误登录
- 第6次应返回封锁提示
2025-11-13 23:22:11 +08:00
19174616018 f6e88d85e7 fix: 修复防爆破保护不生效的问题
问题原因:
- 登录接口缺少recordFailure调用
- 分享密码接口缺少recordFailure和recordSuccess调用

修复内容:
- 在用户不存在时记录失败尝试
- 在密码错误时记录失败尝试
- 在分享密码错误时记录失败尝试
- 在分享密码验证成功时清除失败记录

测试方法:
- 连续5次错误登录后应被封锁30分钟
- 连续10次错误分享密码后应被封锁20分钟
2025-11-13 23:20:25 +08:00
19174616018 b7ecfa4dff docs: 添加 v1.1.0 详细更新文档 2025-11-13 23:14:40 +08:00
19174616018 ac9f1c7052 chore: 发布 v1.1.0 版本
新增功能:
- 登录和分享密码防爆破保护
- 更新脚本自动检测和修复上传工具
- 首页界面优化

安全增强:
- 基于IP和用户名的智能限流
- 支持反向代理场景
- 详细的安全日志
2025-11-13 23:13:24 +08:00
19174616018 72063c56eb fix: 修复更新脚本导致上传工具丢失的问题
- 在更新流程中添加上传工具完整性检查
- 如果上传工具丢失或文件大小异常,自动重新下载
- 使用已有的build_upload_tool函数从直链下载
- 验证文件大小确保下载完整(>30MB)
2025-11-13 22:49:49 +08:00
19174616018 c439966bc5 feat: 添加登录和分享密码防爆破保护
- 新增RateLimiter类实现基于IP和用户名的限流
- 登录接口: 5次失败/15分钟后封锁30分钟
- 分享密码: 10次失败/10分钟后封锁20分钟
- 支持X-Forwarded-For反向代理
- 自动清理过期记录
- 详细的安全日志记录
2025-11-13 22:45:22 +08:00
19174616018 32e436c978 refactor(frontend): 更新首页内容 - 版本号V1.0 + 双存储模式说明
- 修改版本badge: v1.0生产就绪版本 → 版本号 V1.0
- 更新主描述: 支持SFTP远程连接和服务器本地存储两种模式
- Stats区域优化:
  * 移除「100%开源免费」和「Docker一键部署」
  * 新增「双模式 - SFTP + 本地存储」
- 功能卡片更新:
  * 流式下载 → 灵活下载
  * 详细说明三种模式: HTTP直链、SFTP流式、本地存储
- 移除CTA区域的「查看源码」按钮
- 简化Footer: 删除所有导航链接,只保留版权信息
- 响应式优化: 调整stats为单列布局(移动端)
2025-11-13 22:13:50 +08:00
19174616018andClaude c8f3ab5881 feat: 修复CORS安全漏洞 + 升级主页设计
🔒 安全修复:
- 修复分享链接HTTP/HTTPS协议识别问题
- 自动配置CORS安全策略(根据部署模式)
- 自动配置Cookie安全设置(HTTPS环境)
- 移除不安全的默认CORS配置

 功能改进:
- install.sh: 升级create_env_file()函数,智能配置CORS
  * 域名+HTTPS模式: ALLOWED_ORIGINS=https://domain
  * 域名+HTTP模式: ALLOWED_ORIGINS=http://domain
  * IP模式: 留空并显示安全警告
- backend/server.js: 添加getProtocol()函数,正确识别HTTPS
- backend/.env.example: 完全重写,添加详细的CORS配置说明

🎨 主页升级:
- frontend/index.html: 全新现代化设计
  * 渐变背景+动画效果
  * 9大功能特性展示
  * 8项技术栈展示
  * 完美响应式支持

📝 修改文件:
- backend/server.js (第63-83行, 1255行, 1282行)
- install.sh (第2108-2195行)
- backend/.env.example (完全重写)
- frontend/index.html (完全重写)

🔗 相关问题:
- 修复CORS允许任意域名访问的安全漏洞
- 修复分享链接使用HTTP的问题
- 解决Cookie在HTTP环境下的安全隐患

💡 向后兼容:
- 已部署项目可选择性升级
- 手动添加ALLOWED_ORIGINS配置即可生效

🎉 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 21:36:49 +08:00
19174616018 cc3d0d8494 add: 添加分享路径比较修复脚本
修复isPathWithinShare函数中路径前缀斜杠不一致导致的匹配失败问题
2025-11-13 19:49:59 +08:00
19174616018 bfa07c53bb fix: 修复shareOwner变量作用域错误
问题:在/api/share/:code/verify接口中,shareOwner在定义前被使用
修复:将shareOwner定义移至使用之前
2025-11-13 19:32:43 +08:00
19174616018andClaude 7c18e6fea4 fix: 修复storage.js中的正则表达式语法错误
问题:
- getFullPath方法中的正则表达式 [\/\] 语法错误
- 导致"Invalid regular expression: missing /"错误
- 影响文件上传和列表功能

修复:
- 将错误的 [\/\] 修正为 [\/\]
- 添加空值处理 relativePath || ''
- 完善路径处理逻辑,支持Linux和Windows环境

🔐 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 19:25:46 +08:00
19174616018 2f2f712dae Merge branch 'master' of https://gitee.com/yu-yon/vue-driven-cloud-storage 2025-11-13 18:00:34 +08:00
19174616018andClaude 04e9ff5b7e fix: 修复两个安全漏洞
1. 修复分享下载接口越权访问漏洞(高危)
   - 添加isPathWithinShare函数验证请求路径是否在分享范围内
   - 单文件分享只允许下载该文件
   - 目录分享只允许下载该目录及子目录的文件
   - 防止攻击者通过构造path参数访问分享者的任意文件
   - 相关文件:backend/server.js

2. 修复本地存储路径处理问题(中高危)
   - 优化getFullPath方法处理绝对路径的逻辑
   - 修复Linux环境下path.join处理'/'导致的路径错误
   - 将绝对路径转换为相对路径,确保正确拼接用户目录
   - 相关文件:backend/storage.js

🔐 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 18:00:09 +08:00
WanWanYunandClaude d5fada3a26 feat: 移除Certbot方案,统一使用acme.sh + 添加自动续期配置
核心改进:
1. 移除Certbot (方案1)
   - 原因:Python依赖冲突(urllib3)难以解决
   - 用户已用acme.sh成功申请证书
   - acme.sh更轻量、更可靠

2. 优化SSL方案选择菜单
   - 新方案1:acme.sh + Let's Encrypt (推荐)
   - 新方案2:acme.sh + ZeroSSL
   - 新方案3:acme.sh + Buypass
   - 新方案4-5:阿里云/腾讯云
   - 新方案6-7:手动上传/暂不配置

3. 添加自动续期配置功能
   - 新增setup_acme_auto_renew()函数
   - 自动检查并启动cron服务
   - 验证acme.sh自动续期任务
   - 显示续期时间和配置信息
   - 提供续期检查命令

4. 自动续期特性:
   -  每天自动检查证书
   -  证书到期前30天自动续期
   -  续期后自动重载Nginx
   -  无需手动干预

5. 用户体验改进:
   - 显示详细的续期配置信息
   - 提供手动续期命令
   - 统一的续期管理方式
   - 完成提示中显示续期检查命令

技术细节:
- acme.sh安装时自动创建cron任务
- 兼容cron/crond两种服务名
- 支持systemctl和传统service管理
- 显示预计续期时间(从证书配置文件读取)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 15:24:08 +08:00
WanWanYunandClaude 691d4ad075 fix: 处理SSL证书已存在的情况,避免重复申请失败
核心问题:
- 当证书已存在时,acme.sh会报错"Domains not changed"并拒绝申请
- Certbot同样会拒绝重复申请已有的证书
- 导致用户重新运行脚本时SSL部署总是失败

解决方案:
1. acme.sh方案:
   - 申请失败后检查证书是否已在证书列表中
   - 如果已存在,直接使用现有证书进行安装
   - 避免不必要的重复申请

2. Certbot方案:
   - 申请失败后检查 /etc/letsencrypt/live/${DOMAIN} 目录
   - 如果证书文件存在,直接创建软链接到nginx目录
   - 保证证书可以正常使用

3. 应用范围:
   - deploy_certbot() - Certbot方案
   - deploy_acme_letsencrypt() - acme.sh + Let's Encrypt
   - deploy_acme_zerossl() - acme.sh + ZeroSSL
   - deploy_acme_buypass() - acme.sh + Buypass

优点:
-  支持重复运行脚本而不报错
-  充分利用已有的有效证书
-  减少对CA服务器的请求压力
-  避免触发速率限制

用户体验改进:
- 显示友好的"检测到证书已存在"提示
- 自动继续安装流程,无需用户干预

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 15:16:22 +08:00
WanWanYunandClaude 8b9af536c7 fix: 修复SSL证书部署的两个关键问题
问题1:acme.sh证书安装时nginx reload失败
- 错误:nginx.service is not active, cannot reload
- 原因:证书安装时nginx服务未运行
- 解决:
  1. 在安装证书前检查并启动nginx服务
  2. 移除--reloadcmd参数,改为手动reload
  3. 兼容systemctl和直接nginx命令两种方式
  4. 兼容宝塔面板的nginx路径

问题2:Certbot安装/运行时urllib3依赖冲突
- 错误:ImportError: cannot import name 'appengine' from 'urllib3.contrib'
- 原因:系统的python3-urllib3版本与certbot不兼容
- 解决:
  1. 安装certbot前移除冲突的python3-urllib3包
  2. 添加已安装certbot的依赖修复逻辑
  3. 应用到apt/yum/dnf等所有包管理器
  4. 提供详细的错误提示和修复建议

技术改进:
- acme.sh安装证书更可靠(分离安装和reload步骤)
- Certbot依赖检测更完善(检测并修复依赖冲突)
- 错误处理更友好(提示用户尝试其他方案)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 15:09:25 +08:00
WanWanYunandClaude ab577931c3 fix: 修改acme.sh证书申请方式为webroot模式
问题:
- 使用--nginx参数时,acme.sh报错"Cannot find config file for domain"
- nginx模式需要acme.sh能够识别nginx配置文件格式

解决方案:
- 改用--webroot模式,直接使用前端目录作为验证根目录
- webroot模式更可靠,不依赖nginx配置文件解析
- 应用到所有acme.sh方案:Let's Encrypt、ZeroSSL、Buypass

参数变化:
- 旧:--nginx
- 新:--webroot "${PROJECT_DIR}/frontend"

优点:
1. 不需要解析nginx配置文件
2. 直接利用已经配置好的HTTP服务器
3. 兼容性更好,成功率更高

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 15:00:04 +08:00
WanWanYunandClaude 410b85f9e1 fix: 彻底修复acme.sh安装失败问题
问题描述:
- acme.sh安装后,~/.acme.sh目录被创建但主脚本文件不存在
- 导致后续证书申请和安装失败,提示"主脚本文件不存在"

修复内容:
1. 改用官方推荐的curl管道安装方式(curl https://get.acme.sh | sh)
2. 增加不完整安装的检测和清理逻辑
3. 检查条件从仅检查目录改为同时检查目录和文件
4. 增强错误诊断信息,显示目录内容帮助排查
5. 应用到所有acme.sh方案:Let's Encrypt、ZeroSSL、Buypass

技术改进:
- 从下载到临时文件改为直接curl管道执行(更可靠)
- 添加email参数避免交互式输入
- 完善失败提示,引导用户选择其他方案

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 14:45:14 +08:00
WanWanYunandClaude 4bf0cd4edf fix: 修复SSL证书部署时的文件检查问题
修复内容:
1. 修复HTTPS重定向配置 - 根据端口生成正确的重定向URL
2. 增强acme.sh文件存在性检查 - 在申请和安装证书前都检查文件
3. 应用到所有acme.sh方案 - Let's Encrypt、ZeroSSL、Buypass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 14:36:57 +08:00
WanWanYunandClaude 06056563b2 修复: 选项顺序调整 + acme.sh切换到GitHub官方源
问题:
1. 主界面菜单顺序:用户反馈[5]在[4]上面,需要对调
2. SSL选项顺序:阿里云和Buypass编号需要对调
3. acme.sh安装:Gitee镜像不稳定,导致安装失败

修复:
1. 主界面菜单
   - [4] SSL证书管理 (原[5])
   - [5] 卸载玩玩云 (原[4])

2. SSL证书选项(两处:choose_ssl_method + ssl_choose_method)
   - [4] 阿里云免费证书 (原[5])
   - [5] acme.sh + Buypass (原[4])

3. deploy_ssl() 函数
   - case 4: deploy_aliyun_ssl
   - case 5: deploy_acme_buypass

4. ssl_fallback() 函数
   - 备选方案显示和case分支对应调整

5. acme.sh安装源切换
   - deploy_acme_letsencrypt: 移除Gitee镜像检测,统一使用GitHub
   - deploy_acme_zerossl: 移除Gitee镜像检测,统一使用GitHub
   - deploy_acme_buypass: 移除Gitee镜像检测,统一使用GitHub
   - 提示:国内用户会较慢,但更稳定可靠

影响:
- 菜单顺序更合理(危险操作放最后)
- SSL选项分类更清晰
- acme.sh安装成功率大幅提升(GitHub官方源稳定性更好)

注意:
- 其他依赖(npm、系统包)仍使用阿里云镜像加速
- 仅acme.sh使用GitHub官方源

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 14:09:29 +08:00
WanWanYunandClaude 41f823db54 修复: 真正移除acme.sh安装脚本的--install参数
问题:
- v4.0.11修复了安装脚本URL,但忘记移除--install参数
- 官方安装脚本https://get.acme.sh应该不带参数直接运行
- --install是给安装后的acme.sh主程序用的,不是给安装脚本用的
- 导致安装脚本执行后,目录创建但文件未正确安装

修复:
- deploy_acme_letsencrypt: 移除 --install 参数
- deploy_acme_zerossl: 移除 --install 参数
- deploy_acme_buypass: 移除 --install 参数
- 统一使用: bash "$TEMP_INSTALL_SCRIPT" (不带参数)

影响:
- 修复acme.sh安装后目录为空的根本问题
- 确保所有acme.sh方案(Let's Encrypt/ZeroSSL/Buypass)正常工作

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 14:02:10 +08:00
WanWanYun 503a760b17 feat: 添加独立的SSL证书管理模式
新增功能:
- 新增 --ssl/--cert/ssl 命令行参数
- 交互式菜单添加 [5] SSL证书管理 选项
- 可单独配置/更换/续签SSL证书,无需重新部署整个项目

使用场景:
1. 初次部署时选择了"暂不配置HTTPS",现在想添加证书
2. 现有证书即将过期,需要续签(虽然有自动续期,但也可手动)
3. 想更换SSL方案(例如从Certbot换到acme.sh)
4. 想更换域名的SSL证书
5. 想移除HTTPS配置,改回HTTP模式

核心功能:
- ssl_main() - SSL证书管理主流程
- ssl_check_project() - 检查项目是否已安装
- ssl_load_existing_config() - 读取现有配置(域名、端口等)
- ssl_configure_domain() - 配置或更改域名
- ssl_choose_method() - 选择SSL方案(1-8,含移除HTTPS)
- ssl_deploy_certificate() - 部署证书
- ssl_update_nginx_config() - 更新Nginx配置
- ssl_reload_services() - 重载Nginx和后端服务
- ssl_verify_deployment() - 验证部署结果
- print_ssl_completion() - 显示完成信息和使用提示

使用方法:
# 方法1: 命令行参数
bash install.sh --ssl

# 方法2: 交互式菜单
bash install.sh
> 选择 [5] SSL证书管理

# 方法3: 别名
bash install.sh --cert

工作流程:
1. 检查项目是否已安装
2. 读取现有Nginx配置(HTTP/HTTPS端口、域名)
3. 确认或修改域名配置
4. 选择SSL证书方案(支持所有8种方案)
5. 先配置HTTP模式(Let's Encrypt验证需要)
6. 申请/部署SSL证书
7. 更新Nginx为HTTPS配置(或保持HTTP,如果选择8)
8. 重载Nginx和后端服务
9. 验证部署并显示证书信息

特殊选项:
- [8] 移除HTTPS配置 - 将HTTPS站点改回HTTP模式
- [0] 取消操作 - 不做任何修改

技术特点:
- 智能读取现有配置,自动填充域名和端口
- 支持宝塔面板和标准Nginx环境
- 兼容所有SSL方案(Certbot、acme.sh系列、手动证书)
- 自动处理证书路径和Nginx配置
- 显示证书有效期信息

影响范围:
- install.sh 新增 ~450 行代码
- 添加 9 个新函数
- 更新交互式菜单和命令行帮助
2025-11-13 13:56:06 +08:00
WanWanYun 30de3327e8 fix: 修复acme.sh安装失败 - 使用正确的安装脚本URL
问题描述:
- acme.sh安装后目录存在但为空(无acme.sh主脚本文件)
- 用户截图显示:~/.acme.sh目录创建但文件未复制
- 导致所有acme.sh方案(Let's Encrypt/ZeroSSL/Buypass)全部失败

根本原因:
- 错误的Gitee镜像URL:
   https://gitee.com/neilpang/acme.sh/raw/master/acme.sh (主程序文件)
   https://get.acme.sh (官方安装脚本)

- 直接下载主程序文件acme.sh,然后执行 bash acme.sh --install
- 但acme.sh主程序不是安装器,无法自行安装

正确安装流程:
- 官方安装脚本 https://get.acme.sh 会自动:
  1. 创建 ~/.acme.sh 目录
  2. 下载所有必需文件(acme.sh + 依赖)
  3. 设置权限和环境变量

修复方案:
- 统一使用官方安装脚本URL: https://get.acme.sh
- 通过环境变量 ACME_USE_GITEE=1 控制国内镜像加速
- 官方安装脚本会根据此环境变量自动选择Gitee镜像

修复内容:
1. deploy_acme_letsencrypt() - 修复Let's Encrypt方案
2. deploy_acme_zerossl() - 修复ZeroSSL方案
3. deploy_acme_buypass() - 修复Buypass方案

技术细节:
- 国内网络:export ACME_USE_GITEE=1 + curl https://get.acme.sh
- 海外网络:直接 curl https://get.acme.sh
- 安装脚本自动识别环境变量并选择最快镜像

影响范围:
- install.sh:1344-1428 (Let's Encrypt)
- install.sh:1508-1574 (ZeroSSL)
- install.sh:1648-1713 (Buypass)

预期效果:
-  acme.sh成功安装并包含所有必需文件
-  国内网络自动使用Gitee镜像加速
-  证书申请流程顺利进行
2025-11-13 12:58:03 +08:00
WanWanYun ff8ba91b8d fix: 修复后端端口更换时脚本异常退出的问题
问题描述:
- 部署时后端端口被占用,用户更换其他端口后脚本异常退出
- 根因: health_check()函数中netstat命令失败时触发set -e导致脚本退出

修复内容:
1. 端口检查添加错误抑制 (2>/dev/null)
2. 添加ss命令作为netstat的fallback
3. Nginx状态检查直接通过目录检测BT Panel环境
4. 修复变量作用域问题 (IS_BT_PANEL仅在局部函数中定义)

技术细节:
- netstat -tunlp 2>/dev/null || ss -tunlp 2>/dev/null
- 使用 [[ -d /www/server/nginx ]] 直接检测宝塔面板
- 宝塔环境使用pgrep检查nginx进程
- 标准环境使用systemctl is-active检查

影响范围:
- health_check() 函数 (install.sh:2588-2612)
- 提升脚本在端口冲突场景下的健壮性
- 兼容宝塔面板和标准Nginx环境
2025-11-13 12:16:14 +08:00
WanWanYun f7034adb9f fix: 修复Nginx配置,恢复index.html作为主页
- 将Nginx index指令从 app.html 改为 index.html
- 将try_files回退路径从 /app.html 改为 /index.html
- 恢复产品展示主页,而不是直接跳转到登录页
- 影响HTTP和HTTPS配置的所有3处location块
2025-11-13 10:08:12 +08:00
WanWanYun 58d2038ed3 fix: 全面改进SSL证书部署逻辑
**Certbot改进(方案1)**:
- Ubuntu/Debian优先使用snap安装(官方推荐)
- 避免Python依赖冲突(urllib3.contrib.appengine问题)
- snap失败自动降级到apt安装
- 添加安装验证逻辑

**acme.sh改进(方案2-4)**:
- 改用两步安装:先下载到临时文件,验证后执行
- 检查下载文件大小和内容完整性
- 验证脚本内容(检测acme.sh关键词)
- 增加等待时间:2秒→5秒
- 统一三个方案(Let's Encrypt/ZeroSSL/Buypass)

**诊断增强**:
- 添加安装位置查找功能
- 改进错误提示信息
- 提供更详细的排查步骤

**兼容性**:
- 支持Ubuntu/Debian (apt)
- 支持CentOS/RHEL (yum/dnf)
- 支持openSUSE (zypper)
- 兼容宝塔面板环境
2025-11-13 09:57:28 +08:00
WanWanYun 72ec10ef4e fix: configure_nginx_final()函数添加宝塔面板Nginx重载支持
- 修复选择"暂不配置HTTPS"时Nginx重载失败的问题
- 添加与configure_nginx_http_first()相同的三重保障机制
- 支持宝塔面板环境的nginx命令直接操作
2025-11-13 09:50:39 +08:00
WanWanYun c1b8e7b929 fix: 优化宝塔面板Nginx重载逻辑,添加多种重启方式
- 添加BT Panel专用的Nginx重载流程
- 尝试多种方法:BT CLI工具、直接nginx命令、systemctl备用
- 改用进程检查(pgrep)验证Nginx状态,不依赖systemctl
- 修复BT Panel环境下nginx启动失败的问题
2025-11-13 09:39:47 +08:00
WanWanYun 0f53d0638c fix: 添加宝塔面板(BT Panel)Nginx配置支持
问题:
- 用户使用宝塔面板,Nginx配置路径为 /www/server/nginx/
- 标准路径 /etc/nginx/ 不适用于宝塔
- systemctl restart nginx 导致宝塔Nginx启动失败

宝塔特征:
- 配置文件:/www/server/nginx/conf/nginx.conf
- 虚拟主机:/www/server/panel/vhost/nginx/*.conf
- 需要使用reload而不是restart

修复:
1. 添加宝塔面板检测逻辑(检查 /www/server/nginx 目录)
2. 使用宝塔专用配置目录:/www/server/panel/vhost/nginx/
3. 宝塔环境使用reload,避免影响其他站点
4. 配置文件优先级:
   - 宝塔面板 > Debian/Ubuntu > CentOS/RHEL
5. 所有三个Nginx配置函数都已更新

修复的函数:
- configure_nginx_http_first() - 检测宝塔并使用专用目录
- configure_nginx_http() - 同上
- configure_nginx_https() - 同上

预期效果:
- 宝塔用户:配置写入 /www/server/panel/vhost/nginx/wanwanyun.conf
- 宝塔用户:使用 systemctl reload nginx
- 标准Nginx:行为不变

相关错误:
Job for nginx.service failed because the control process exited with error code
2025-11-13 09:32:20 +08:00
WanWanYun 66f9a30c5c fix: 修复Nginx配置目录兼容性,确保sites-available目录存在
问题:
- Ubuntu 22.04系统在configure_nginx_http_first()时报错
- 错误:/etc/nginx/sites-available/wanwanyun.conf: No such file or directory
- 原因:部分Ubuntu/Debian系统Nginx安装后未自动创建sites-available目录

修复:
1. 在所有Nginx配置函数中添加目录检测和创建逻辑
2. 使用mkdir -p确保目录存在后再写入配置文件
3. 兼容两种Nginx配置结构:
   - Ubuntu/Debian: sites-available + sites-enabled (软链接)
   - CentOS/RHEL: conf.d (直接加载.conf文件)
4. 根据PKG_MANAGER变量判断系统类型(更可靠)

修复的函数:
- configure_nginx_http_first() - 两阶段部署的第一步
- configure_nginx_http() - HTTP模式配置
- configure_nginx_https() - HTTPS模式配置

预期效果:
- Ubuntu/Debian系统:自动创建sites-available和sites-enabled目录
- CentOS/RHEL系统:使用conf.d目录
- 不再因为目录不存在而部署失败

相关错误:
line 1964: /etc/nginx/sites-available/wanwanyun.conf: No such file or directory
2025-11-13 09:27:01 +08:00
WanWanYun bb073232c4 fix: 增强acme.sh安装验证,立即检查目录是否创建
问题:
- 用户反馈方法2-4(acme.sh系列)显示"安装目录不存在"
- 安装命令返回成功(exit 0)但目录未创建
- 导致验证阶段报错但无法诊断原因

原因分析:
- 安装命令可能显示帮助文本而非真正安装
- 仅依赖exit code无法判断是否真正安装成功
- 缺少安装后的立即验证

改进:
1. 捕获安装命令的退出码(不再使用if判断)
2. 安装后立即检查目录和文件是否创建
3. 验证条件:exit code = 0 AND 目录存在 AND 文件存在
4. 失败时显示详细诊断信息:
   - 安装命令退出码
   - 目录是否存在
   - 文件是否存在
   - HOME环境变量
   - 当前用户
5. 统一三个acme.sh函数的验证逻辑

预期效果:
- 如果安装真的失败,会显示详细诊断信息
- 用户和开发者可以根据诊断信息判断具体问题
- 不再有"显示成功但实际失败"的情况

影响函数:
- deploy_acme_letsencrypt()
- deploy_acme_zerossl()
- deploy_acme_buypass()

相关提交:
- 18512d9 (增强acme.sh验证逻辑)
- 4a73a8c (Gitee镜像加速)
2025-11-13 08:53:17 +08:00
WanWanYun 565bf7dffc fix: 修改Certbot为webroot模式,配合两阶段Nginx部署
问题:
- Certbot使用--nginx插件时需要已存在的server block
- 原执行顺序在Nginx配置前就部署SSL,导致找不到server block

改进:
1. 改用certonly --webroot模式(不自动修改Nginx配置)
2. 使用项目frontend目录作为webroot进行域名验证
3. 手动创建证书软链接到/etc/nginx/ssl/(与其他方法统一)
4. 配合新的两阶段Nginx部署流程:
   - 阶段1: configure_nginx_http_first() 先创建HTTP server block
   - 阶段2: deploy_ssl() 可以通过HTTP验证域名
   - 阶段3: configure_nginx_final() 根据SSL结果配置HTTPS

修复错误:
"Could not automatically find a matching server block for [domain]"

相关提交:
- 7c4e1ed (重构Nginx配置为两阶段部署)
- 18512d9 (增强acme.sh验证)
- 4a73a8c (Gitee镜像加速)
2025-11-13 08:51:13 +08:00
WanWanYun 18512d92ed fix: 增强acme.sh安装验证逻辑,添加详细诊断信息
问题:
- acme.sh安装显示成功,但文件验证立即失败
- 无法判断具体原因(路径/权限/时序问题)

改进:
1. 添加2秒等待,确保文件系统同步
2. 分步骤验证:目录 → 文件 → 权限 → 运行
3. 失败时显示目录内容,方便诊断
4. 自动修复权限问题(chmod +x)
5. 测试脚本实际可运行性(--version)

影响范围:
- deploy_acme_letsencrypt()
- deploy_acme_zerossl()
- deploy_acme_buypass()

相关提交:
- e915d5e (添加基础错误检查)
- 4a73a8c (Gitee镜像加速)
- a7aca93 (智能SSL fallback)
2025-11-13 01:51:22 +08:00
WanWanYunandClaude a7aca93355 feat: ssl_fallback智能排除已失败的方案
问题描述:
- 用户选择方案2失败后,fallback还推荐方案2
- 用户再次选择方案2,还是会失败
- 浪费时间,用户体验差

修复方案:
- ssl_fallback函数接收失败的方案编号作为参数
- 动态生成可用选项,排除已失败的方案
- 显示提示:方案X已失败,已从列表中移除
- 如果备选方案再次失败,递归调用fallback并排除

改进内容:
1. ssl_fallback($failed_method) - 接收方案编号
2. 动态显示可用选项(排除失败方案)
3. 输入验证:只接受可用选项
4. 递归调用:如果备选方案也失败,继续排除

方案编号映射:
- 1: Certbot
- 2: acme.sh + Let's Encrypt
- 3: acme.sh + ZeroSSL
- 4: acme.sh + Buypass
- 5: 阿里云(未实现)
- 6: 腾讯云(未实现)
- 8: 不配置HTTPS

用户体验改进:
- 避免重复尝试失败的方案
- 清晰提示哪个方案已失败
- 自动缩小选择范围
- 节省用户时间

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 01:46:05 +08:00
WanWanYunandClaude 4a73a8c348 fix: 修复Gitee镜像加速仍从GitHub下载的问题
问题描述:
- 虽然显示"使用Gitee镜像加速"
- 但acme.sh安装脚本内部仍从GitHub下载tar.gz包
- 导致国内网络安装失败

根本原因:
- 使用了 --install-online 参数
- acme.sh脚本会在线下载master.tar.gz
- 默认还是从GitHub下载

修复方案:
- 使用acme.sh官方推荐的方法
- 设置环境变量 ACME_USE_GITEE=1
- 让acme.sh内部也使用Gitee源
- 移除 --install-online 参数

官方文档:
https://github.com/acmesh-official/acme.sh/wiki/Install-in-China

修复代码:
# 国内网络
export ACME_USE_GITEE=1
curl https://gitee.com/neilpang/acme.sh/raw/master/acme.sh | sh

修复函数:
- deploy_acme_letsencrypt
- deploy_acme_zerossl
- deploy_acme_buypass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 01:41:14 +08:00
WanWanYunandClaude e915d5e4db fix: 修复acme.sh系列SSL证书申请失败误报成功的问题
问题描述:
- acme.sh安装失败(GitHub连接超时)但显示"成功"
- 证书申请失败但显示"成功"
- 导致Nginx配置找不到证书文件而失败退出

修复内容:
1. 所有acme.sh函数添加返回码检查
2. 安装失败时正确返回失败状态
3. 证书申请失败时正确返回失败状态
4. 证书安装失败时正确返回失败状态

新增功能:
- 检测网络环境(海外/中国大陆)
- 国内网络自动使用Gitee镜像加速
- 详细的步骤提示(安装/申请/部署)
- 失败时显示常见原因

修复函数:
- deploy_acme_letsencrypt: 完整重写,添加所有检查
- deploy_acme_zerossl: 添加返回码检查和镜像支持
- deploy_acme_buypass: 添加返回码检查和镜像支持

网络优化:
- 海外: 使用官方源 https://get.acme.sh
- 国内: 使用Gitee镜像 https://gitee.com/neilpang/acme.sh

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 01:36:59 +08:00
WanWanYunandClaude 417464c639 fix: 修复SSL证书申请失败后仍显示成功的问题
问题描述:
- Certbot申请证书失败后,脚本误报为成功
- 导致后续Nginx配置时找不到证书文件而失败退出

修复内容:
- 检查certbot命令的返回码,正确判断成败
- 失败时显示常见失败原因
- 通过ssl_fallback函数提供备选方案
- 用户可选择其他SSL方案或暂不配置HTTPS

改进逻辑:
1. certbot执行成功 → 配置HTTPS
2. certbot执行失败 → 调用ssl_fallback
3. ssl_fallback提供选项:
   - 尝试其他SSL方案(acme.sh等)
   - 暂不配置HTTPS(使用HTTP模式)
4. SSL_METHOD=8时正确使用HTTP配置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 01:22:36 +08:00
WanWanYunandClaude 59a693ff15 fix: 修复智能端口检测在set -e模式下导致脚本退出的问题
问题描述:
- check_port_status函数返回非零值导致脚本在set -e模式下退出
- 使用grep -oP在某些Debian系统上不兼容

修复内容:
- 修改check_port_status始终返回0,避免触发set -e退出
- 将grep -oP替换为sed命令,提高兼容性
- 通过echo返回状态字符串而非返回码

测试场景:
- Debian 12系统验证通过
- 端口检测功能正常工作

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 01:12:13 +08:00
WanWanYunandClaude 20d23509e7 feat: 添加智能端口检测和Nginx虚拟主机支持 (v4.0)
新增功能:
- 智能识别占用端口的进程(Nginx/Apache/其他)
- 支持Nginx虚拟主机模式,多网站共用80/443端口
- 根据不同进程提供针对性解决方案
- 优化用户交互体验,提供清晰的选项说明

改进内容:
- 新增check_port_status()函数识别进程类型
- 改进configure_ports()函数支持虚拟主机选择
- 添加SHARE_NGINX标志跟踪部署模式
- 443端口智能继承80端口的选择

技术要点:
- Nginx占用端口时提供共用选项(推荐)
- Apache占用时提示冲突并给出解决方案
- 其他进程占用时引导用户更换端口
- 完整保留v3.0的CORS双重来源支持

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 01:07:22 +08:00
WanWanYunandClaude cfcbc22ae7 修复: 使用临时状态变量兼容SFTP配置显示逻辑
问题描述:
- 用户希望本地存储模式时隐藏SFTP配置区域
- 但点击"切换到SFTP"时需要能看到SFTP配置表单进行填写

解决方案:
1. 添加forceSftpConfigVisible状态变量用于临时强制显示
2. 修改HTML v-if条件,增加forceSftpConfigVisible的判断
3. 在switchStorage中设置forceSftpConfigVisible为true
4. 在updateFtpConfig成功后重置为false
5. 在switchView切换视图时自动重置

效果:
- 本地存储模式:SFTP配置默认隐藏
- 点击切换到SFTP:临时显示SFTP配置区域并滚动到该区域
- 配置成功:自动切换到SFTP模式,标志自动重置
- 切换视图:标志自动重置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:31:26 +08:00
WanWanYunandClaude 1c58e498c5 修复: 解决切换到SFTP配置页面无响应的根本问题
问题分析:
1. SFTP配置区域的v-if条件包含了storageType检查
2. 当用户为local存储时,SFTP配置区域被隐藏
3. 即使跳转到settings页面,用户也看不到SFTP配置表单

修复内容:
1. 移除SFTP配置区域v-if中的storageType检查
2. 只要用户权限允许SFTP,配置区域就始终可见
3. 添加id属性,方便定位和滚动
4. 在switchStorage中添加平滑滚动到SFTP配置区域的逻辑
5. 使用$nextTick确保DOM更新后再滚动

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:20:06 +08:00
WanWanYunandClaude 74f25ce3ed 修复: 解决切换到SFTP设置页面无响应的问题
- 问题:当用户已在settings页面时,switchView方法会检测到重复而直接return
- 修复:直接设置currentView并手动调用loadFtpConfig()
- 确保无论用户在哪个页面,点击确认后都能正确跳转到设置页面

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 23:10:23 +08:00
WanWanYunandClaude fd52594b83 修复: 切换到SFTP设置页面时使用switchView方法
- 修正switchStorage方法中跳转设置页面的逻辑
- 从直接修改currentView改为调用switchView方法
- 确保视图切换时正确触发数据加载(如SFTP配置表单)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 22:59:29 +08:00
WanWanYunandClaude a791569b17 修复: 优化存储切换逻辑,解决SFTP配置和权限切换问题
1. 登录时智能修正存储类型:
   - 当用户为SFTP模式但未配置SFTP时,如果有本地存储权限,自动切换到本地存储
   - 避免管理员更改用户权限后,用户登录时出现"加载文件失败"错误

2. SFTP配置后自动切换存储模式:
   - 用户成功配置SFTP后,自动切换到SFTP存储模式
   - 无需用户手动再次切换,提升用户体验

3. 改进存储切换提示信息:
   - 当用户尝试切换到未配置的SFTP时,显示更友好的提示
   - 明确告知用户配置完成后将自动切换到SFTP存储

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 22:50:27 +08:00
WanWanYun e5ba17329c 功能: 添加管理员上传工具检测和上传功能
- 后端: 添加 GET /api/admin/check-upload-tool 检测工具是否存在
- 后端: 添加 POST /api/admin/upload-tool 允许管理员手动上传exe
- 前端: 管理员界面新增上传工具管理卡片
- 前端: 支持检测工具状态、显示文件信息、手动上传
- 验证: 文件必须是.exe格式且大小>20MB
2025-11-12 20:45:17 +08:00
19174616018andClaude 0fc378576f 改进: 增强上传工具下载的可靠性和错误提示
问题背景:
- 用户反馈SFTP模式下载上传工具失败,浏览器无反应
- 服务器日志显示: [上传工具] exe文件不存在
- 安装脚本显示"安装成功",但exe实际下载失败
- 原因:下载失败但脚本继续执行(return 0),用户未注意到警告

改进内容:
1. 添加重试机制
   - 最多重试3次
   - 每次失败后等待5秒
   - 显示当前尝试次数

2. 增加超时时间
   - wget: --timeout=300 (5分钟)
   - curl: --max-time 300 (5分钟)
   - 避免下载43MB文件时超时

3. 文件大小验证
   - 下载前:检查现有文件大小,异常则重新下载
   - 下载后:验证文件至少30MB(正常42MB)
   - 文件过小则删除并提示错误

4. 改进错误提示
   - 下载中:显示文件大小和预计时间
   - 失败时:列出可能的原因
   - 提供手动下载链接和路径

5. 进度显示优化
   - 显示当前尝试次数
   - 显示文件大小(MB)
   - wget添加 --no-check-certificate 避免证书问题

测试场景:
 正常下载(文件不存在)
 跳过下载(文件已存在且大小正常)
 重新下载(文件存在但大小异常)
 网络失败重试(最多3次)
 下载超时处理(5分钟超时)

用户体验:
- 明确知道下载进度
- 失败时有详细的错误信息和解决方案
- 不会误以为安装成功但功能不可用

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 20:06:30 +08:00
WanWanYunandClaude 136d45dee7 优化: 分享所有文件时支持点击文件图标放大查看,并可返回列表
功能描述:
- 在分享所有文件(目录分享)时,点击文件图标可以放大查看
- 显示效果与分享单个文件时相同(居中大图标显示)
- 左上角显示"返回列表"按钮,点击可返回文件列表
- 单个文件分享时不显示返回按钮(避免混淆)

实现内容:
1. 添加状态管理
   - viewingFile: 当前正在查看的文件(null表示在列表视图)

2. 新增方法
   - handleFileClick(file): 处理文件点击
     * 图片/视频/音频 → 打开媒体预览(保持原有行为)
     * 其他文件 → 显示详情页面
   - viewFileDetail(file): 设置viewingFile显示详情
   - backToList(): 清除viewingFile返回列表

3. 修改HTML模板
   - 标题添加返回按钮(条件:viewingFile存在且不是单文件分享)
   - 单文件显示条件:viewingFile || files.length === 1
   - 网格/列表视图条件:添加 !viewingFile 限制
   - 单文件显示内容:使用 viewingFile || files[0]

4. 防止bug设计
   - shareInfo.share_type !== 'file' 确保单文件分享不显示返回按钮
   - handleFileClick 区分媒体文件和普通文件
   - viewingFile 为null时正常显示文件列表

用户体验提升:
 点击文件图标即可放大查看详情
 查看体验与单文件分享一致
 返回按钮清晰明了
 媒体文件仍然打开预览
 单文件分享不显示返回按钮(避免混淆)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 18:24:29 +08:00
WanWanYunandClaude 1e19b41f48 优化: 切换视图时自动刷新数据,解决切换存储方式后需要手动刷新的问题
问题描述:
- 用户在设置页面切换存储方式(本地 ↔ SFTP)
- 点击导航栏回到"我的文件"页面
- 文件列表不会自动刷新,显示的还是旧存储方式的文件
- 需要手动刷新浏览器才能看到新存储方式的文件

解决方案:
1. 新增 switchView(view) 方法统一管理视图切换
   - 切换到 files 视图:自动加载文件列表
   - 切换到 shares 视图:自动加载分享列表
   - 切换到 admin 视图:自动加载用户列表和存储统计
   - 切换到 settings 视图:无需加载数据
   - 防止重复切换:如果已在当前视图则跳过

2. 修改导航栏点击事件
   - 从 @click="currentView = 'xxx'"
   - 改为 @click="switchView('xxx')"
   - 应用到所有4个导航按钮

改进效果:
 用户切换存储方式后,点击"我的文件"立即看到新数据
 所有视图切换都会自动刷新对应数据
 提升用户体验,无需手动刷新页面

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 15:49:02 +08:00
19174616018andClaude 0dc2ac9188 功能: 自动检测并升级C++编译器版本
问题背景:
- better-sqlite3 v11+ 需要C++20标准支持
- C++20需要g++ 10或更高版本
- Ubuntu 20.04等老系统默认g++ 9.x,不支持C++20
- 导致WSL2用户编译失败: "unrecognized command line option '-std=c++20'"

实现内容:
1. 新增check_cpp_compiler()函数
   - 检测g++是否已安装
   - 获取当前g++版本号
   - 如果版本<10,自动升级

2. 支持多个Linux发行版:
   - Ubuntu/Debian: 添加toolchain PPA,安装g++-11
   - CentOS 7: 安装devtoolset-11
   - CentOS 8+: 安装gcc-toolset-11
   - OpenSUSE: 安装gcc11-c++

3. 自动设置为默认编译器:
   - 使用update-alternatives设置优先级
   - CentOS使用source启用gcc-toolset

4. 调用时机:
   - 安装模式:install_dependencies()末尾
   - 更新模式:update_install_dependencies()中

5. 错误处理:
   - 升级失败时给出明确提示
   - 不支持的系统给出手动升级建议

测试场景:
-  Ubuntu 20.04 (g++ 9.x → 11.x)
-  CentOS 7/8
-  已有g++ 10+的系统(跳过升级)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 15:18:01 +08:00
19174616018andClaude 59721a3a93 修复: 更正Python配置方式,解决npm错误
问题:
- 使用了错误的命令: npm config set python python3
- npm报错: 'python' is not a valid npm option
- 导致脚本在安装依赖时直接退出

修复:
- 删除错误的 npm config set python python3 命令
- 改用环境变量方式: PYTHON=python3 npm install --production
- 这是node-gyp官方推荐的方式

测试:
- 环境变量会在npm install执行期间生效
- node-gyp会自动识别PYTHON环境变量
- 不会产生invalid option错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 12:13:53 +08:00
19174616018andClaude ee3c522a37 修复: 改进npm依赖安装,解决better-sqlite3编译失败问题
问题分析:
- better-sqlite3是native模块,需要node-gyp编译
- node-gyp需要Python环境
- 部分系统只有python3命令,没有python命令
- 导致编译失败: "python3" is not in PATH or produced an error

修复内容:
1. 在安装和更新依赖前自动创建python软链接(python -> python3)
2. 配置npm使用python3: npm config set python python3
3. 添加依赖安装进度提示(避免用户以为卡住)
4. 改进错误处理和提示信息
5. 安装失败时提供详细的解决方案和日志路径
6. 支持用户选择忽略错误继续安装

适用场景:
- Ubuntu/Debian: 使用ln -sf创建软链接
- CentOS/RHEL: 使用alternatives管理Python版本
- 其他系统: 回退到ln -sf

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 12:01:44 +08:00
WanWanYunandClaude c272724c5c 修复: 防止管理员封禁自己的账号
- 在封禁用户API中添加自我封禁检查
- 当管理员尝试封禁自己时返回400错误
- 错误消息: "不能封禁自己的账号"
- 解封自己不受限制(虽然被封禁后无法登录后台操作)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 11:38:02 +08:00
19174616018andClaude 644c468a67 功能: 在安装脚本主菜单添加退出选项
- 在操作模式选择菜单中添加 [0] 退出脚本 选项
- 修改输入提示从 [1-4] 改为 [0-4]
- 在case语句中添加选项0的处理逻辑
- 选择0时显示"正在退出脚本..."并正常退出

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 11:24:52 +08:00
WanWanYunandClaude 28beee695a 修复: 添加自动同步用户配置功能,解决管理员修改权限后用户不自动更新的问题
- 添加profileCheckInterval属性用于存储定时器ID
- 修改loadUserProfile()函数,检测存储配置变更并通知用户
- 新增startProfileSync()方法,每30秒自动检查配置更新
- 新增stopProfileSync()方法,停止定期检查
- 在登录和页面加载时启动定期检查
- 在登出时停止定期检查
- 当管理员修改用户存储权限或类型时,用户会收到Toast通知并自动刷新文件列表

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 09:25:17 +08:00
WanWanYun 175087c894 修复: SFTP存储切换问题 & 添加开发者工具保护
- 修复SFTP按钮被错误禁用的问题
- 用户选择本地存储后可以正常切换回SFTP
- 切换到SFTP时检查是否已配置,未配置则跳转到设置页面
- 添加右键菜单禁用
- 添加F12和开发者工具快捷键禁用
- 添加开发者工具打开检测
- 生产环境禁用console输出
2025-11-12 09:13:12 +08:00
19174616018 229a381a63 优化: 简化上传工具下载提示信息
- 去掉操作系统检测提示
- 统一提示"正在下载上传工具..."
- 明确上传工具仅支持Windows平台
- 修复变量引用问题
2025-11-12 00:50:37 +08:00
19174616018 f730fcfaff 配置: 更新上传工具下载地址
- 使用用户提供的CDN地址: http://a.haory.top/e/e82/
- Windows版本直接下载
- 部署时自动下载到upload-tool/dist目录
- 无需Python环境,无需手动打包
2025-11-12 00:46:38 +08:00
19174616018 c76808f03b 优化: 改为从Release下载预编译上传工具
- 不再需要服务器Python环境
- 支持从Gitee Release直接下载exe
- 自动检测操作系统下载对应版本
- 支持wget和curl两种下载方式
- 下载失败时友好提示,不影响部署
2025-11-12 00:42:44 +08:00
19174616018 160201b942 功能: 部署时自动打包上传工具
- 添加build_upload_tool()函数自动打包上传工具
- 检测已存在的可执行文件,避免重复打包
- 打包失败时给出友好提示,不影响部署流程
- 支持Windows exe和Linux可执行文件自动打包
2025-11-12 00:38:01 +08:00
WanWanYun cb05fa9516 功能: 添加Linux上传工具打包脚本
- 新增upload-tool/build.sh支持Linux环境打包
- 支持自动检测操作系统并打包对应版本
- 为部署脚本集成自动打包功能做准备
2025-11-12 00:37:28 +08:00
WanWanYun 2dc6323554 修复: 移除API地址硬编码,统一使用nginx代理
- 修复frontend/app.js中的localhost:40001硬编码
- 修复frontend/share.html中的localhost:40001硬编码
- 所有API请求现在统一通过nginx代理访问
- 支持任意端口号部署,无需修改前端代码
2025-11-11 23:54:40 +08:00
19174616018 340e328b76 修复: install.sh自动配置PUBLIC_PORT环境变量
- 部署时自动添加PUBLIC_PORT到.env文件
- 值为nginx监听的HTTP端口(HTTP_PORT变量)
- 解决非标准端口部署时分享链接缺少端口号的问题
- 标准端口(80/443)不会在分享链接中显示端口号
2025-11-11 23:42:43 +08:00
WanWanYun 016879885c 修复: 移动端UI和部署配置优化
1. 移动端文件列表图片图标大小修复
   - 添加响应式CSS规则,图片缩略图自动适配屏幕尺寸
   - 移除内联样式,统一使用CSS类管理
   - 移动端(768px): 48px x 48px
   - 小屏幕(480px): 40px x 40px

2. 设置页面存储切换UI优化
   - 用户选择本地存储时自动隐藏SFTP配置表单
   - 用户选择SFTP时显示SFTP配置表单
   - 优化用户体验,避免混淆

3. install.sh自动配置PUBLIC_PORT
   - 部署时自动添加PUBLIC_PORT环境变量
   - 非标准端口部署时分享链接自动包含端口号
   - 解决IP模式部署分享链接无法访问的问题
2025-11-11 23:41:17 +08:00
WanWanYun 0c55b599ef 修复: 非标准端口部署时分享链接未携带端口号的问题
- 添加 generateShareUrl 辅助函数,智能处理端口号
- 支持通过 PUBLIC_PORT 环境变量配置nginx监听端口
- 更新 POST /api/share/create 使用新的URL生成方法
- 更新 GET /api/share/my 使用新的URL生成方法
- 修复IP模式部署时分享链接无法正常访问的问题
2025-11-11 23:25:05 +08:00
19174616018 88f2b152f6 修复: 分享下载使用分享者当前存储类型而非创建时存储类型
- 问题: 用户切换到本地存储后,旧分享仍使用创建时的SFTP存储类型,导致404错误
- 修复: 在4个位置将share.storage_type改为shareOwner.current_storage_type
  - /api/share/:code/verify (缓存未命中逻辑)
  - /api/share/:code/verify (错误回退逻辑)
  - /api/share/:code/list (文件列表)
  - /api/share/:code/download-file (文件下载)
- 重构: 调整代码顺序,先获取shareOwner再定义storageType
- 日志: 添加"(分享者当前)"标识,便于调试
2025-11-11 18:29:20 +08:00
19174616018 2390b6c91c 优化: 改进repair模式的服务验证逻辑
1. 在repair_verify_services中添加3秒延迟等待服务启动
2. 修正数据库文件路径检查(ftp-manager.db而非database.db)
3. 避免因服务启动延迟导致的误报警告
2025-11-11 18:07:57 +08:00
19174616018 be363f36b3 修复: 修复repair模式中的nginx配置备份逻辑
问题:
- 备份文件保存在sites-enabled目录导致nginx测试失败
- nginx会读取该目录下所有文件包括旧的错误备份

解决方案:
- 将备份文件保存到/root/目录而不是sites-enabled
- 在生成新配置前先清理旧的.backup.*文件
- 使用2>/dev/null || true避免清理不存在文件时报错
2025-11-11 17:50:41 +08:00
19174616018 81738943d5 修复: Nginx配置中/s/路径的proxy_set_header变量转义问题
- 在configure_nginx_http()和configure_nginx_https()中
- 将$host、$remote_addr等Nginx变量正确转义
- 修复'invalid number of arguments'错误
2025-11-11 17:42:18 +08:00
19174616018 f2ceaf68bf 修复: 将repair_main函数移至主执行流程之前以避免'command not found'错误 2025-11-11 17:32:28 +08:00
19174616018 6504026eb6 修复: 更正交互式菜单中卸载选项的编号从[3]到[4] 2025-11-11 17:05:51 +08:00
19174616018 8755a50d80 功能: 添加修复模式到install.sh
修复模式功能说明:
- 重新加载和应用所有配置
- 保留数据库和用户数据
- 重新生成nginx配置
- 重启后端服务和nginx
- 适用于配置更新、修复nginx问题等场景

使用方法:
  bash install.sh --repair
  bash install.sh --fix
  bash install.sh repair

交互式菜单中添加第3个选项「修复/重新配置」

实现内容:
1. 模式检测: 支持 --repair, --fix, repair 参数
2. 交互式菜单: 添加修复选项到主菜单
3. 主执行流程: 添加repair模式路由到repair_main()
4. repair_main(): 主修复流程函数
   - confirm_repair(): 确认修复操作
   - repair_check_project(): 检查项目是否已安装
   - repair_load_existing_config(): 读取现有.env和nginx配置
   - repair_regenerate_nginx_config(): 重新生成nginx配置
   - repair_restart_services(): 重启PM2和nginx
   - repair_verify_services(): 验证服务状态
   - print_repair_completion(): 显示完成信息

修复: 修正了之前提交的语法错误
- 使用sed命令精确修改,避免破坏文件结构
- 通过bash -n语法检查确保无错误
- 保持代码缩进和格式一致性
2025-11-11 16:59:34 +08:00
19174616018 91fa2d4fa4 功能: 添加修复模式到install.sh
修复模式功能说明:
- 重新加载和应用所有配置
- 保留数据库和用户数据
- 重新生成nginx配置
- 重启后端服务和nginx
- 适用于配置更新、修复nginx问题等场景

使用方法:
  bash install.sh --repair
  bash install.sh --fix
  bash install.sh repair

交互式菜单中添加第3个选项「修复/重新配置」

实现内容:
1. 模式检测: 支持 --repair, --fix, repair 参数
2. 交互式菜单: 添加修复选项到主菜单
3. 主执行流程: 添加repair模式路由到repair_main()
4. repair_main(): 主修复流程函数
   - confirm_repair(): 确认修复操作
   - repair_check_project(): 检查项目是否已安装
   - repair_load_existing_config(): 读取现有.env和nginx配置
   - repair_regenerate_nginx_config(): 重新生成nginx配置
   - repair_restart_services(): 重启PM2和nginx
   - repair_verify_services(): 验证服务状态
   - print_repair_completion(): 显示完成信息
2025-11-11 16:45:16 +08:00
19174616018 0373e199a2 修复: 修复分享链接404错误 - install.sh中的nginx配置错误
## 问题
分享链接访问时报404错误,例如 /s/OjTHZiPg 无法访问

## 原因分析
install.sh中nginx配置有误:

问题:
1. location /s 会匹配 /s 和 /s/xxx
2. 访问 /s/OjTHZiPg 时,nginx会查找文件 frontend/OjTHZiPg/share.html
3. 实际应该代理到后端 /s/:code 路由,让后端重定向到 /share.html?code=xxx

## 修复内容
将 location /s 改为代理到后端:

修复范围:
- HTTP配置(第1196-1200行)
- HTTPS配置(第1269-1273行)

## 部署说明
已部署用户需要:
1. 拉取最新代码
2. 重新运行安装脚本,或手动更新nginx配置
3. 重载nginx: systemctl reload nginx

影响:分享功能将恢复正常
2025-11-11 16:31:42 +08:00
19174616018 230937eba8 修复: 添加原密码验证UI并修复中文文件名乱码
## 问题1: 修改密码缺少原密码验证UI

后端已有current_password验证,但前端没有输入框:
- 用户无法输入当前密码
- 导致密码修改功能无法正常使用

修复内容(前端):
1. app.html: 添加当前密码输入框
2. app.js:
   - 添加current_password字段到data
   - 添加current_password必填验证
   - 请求体中包含current_password
   - 成功后清空current_password

## 问题2: 中文文件名上传后乱码

原因:
- multer默认将文件名从UTF-8转换为Latin1编码
- req.file.originalname获取到的是乱码

修复内容(后端):
1. 配置multer.diskStorage自定义文件名处理
2. 在filename回调中将Latin1转回UTF-8:
   Buffer.from(file.originalname, 'latin1').toString('utf8')
3. 在上传路由中同样转换originalname
4. 临时文件名使用时间戳+随机数+原始文件名避免冲突

影响范围:
- 所有文件上传操作
- 中文、日文、韩文等非ASCII文件名

测试建议:
- 上传中文文件名文件(如测试文档.pdf)
- 上传emoji文件名
- 修改密码功能完整流程测试
2025-11-11 16:09:49 +08:00
19174616018 9eade3e3e6 性能: 优化磁盘信息获取方式,改为异步执行
问题:
- /api/admin/storage-stats 使用 execSync 同步执行 df/wmic 命令
- 同步执行会阻塞事件循环,影响并发请求处理
- 磁盘信息获取可能需要几百毫秒,影响响应速度

优化内容:
1. 导入 util.promisify 将 exec 转换为异步函数
2. 将路由处理函数改为 async
3. 使用 execAsync 替代 execSync 执行 df 命令
4. 使用 execAsync 替代 execSync 执行 wmic 命令

优势:
- 不阻塞事件循环,提升并发性能
- 管理员查看存储统计时不影响其他用户操作
- 更符合 Node.js 最佳实践
2025-11-11 15:24:39 +08:00
19174616018 60bc89ffea 修复: 修复文件下载时storage连接资源泄漏问题
问题:
- /api/files/download 路由在文件流下载时没有关闭storage连接
- 当下载大文件或多个并发下载时,会导致SFTP连接积累
- 可能导致连接池耗尽和内存泄漏

修复内容:
1. 添加stream.on('error')事件处理:流错误时关闭storage连接
2. 添加stream.on('close')事件处理:流传输完成时关闭storage连接
3. 增强catch块:在stream创建之前发生错误时关闭storage连接

影响范围:
- /api/files/download(用户文件下载)
- /api/share/:code/download-file(分享文件下载,已有处理逻辑)

测试建议:
- 测试正常文件下载
- 测试下载过程中断(用户取消下载)
- 测试下载不存在的文件(错误处理)
- 测试大文件下载和并发下载
2025-11-11 15:06:20 +08:00
19174616018andClaude 2a58380b32 修复: 优化存储配额检查时机,支持文件覆盖场景
问题描述:
- 上传文件覆盖已有文件时配额检查不准确
- 只检查新文件大小,不考虑旧文件会被删除释放的空间
- 导致误报配额不足,阻止合理的文件覆盖操作
- 空间统计不准确,累积误差

修复内容:
1. 智能配额检查
   - 上传前检测目标文件是否存在
   - 计算净增量(新文件大小 - 旧文件大小)
   - 只在净增量为正时才检查配额
   - 覆盖更小文件时不检查配额

2. 准确的空间更新
   - 使用净增量更新已使用空间
   - 支持负增量(文件变小时减少用量)
   - 避免重复计算导致的累积误差

3. 容错处理
   - try-catch捕获旧文件不存在的情况
   - 确保即使旧文件检查失败也能继续

修复场景示例:
- 配额10GB,已使用9.5GB
- 有一个1GB的旧文件
- 上传0.8GB新文件覆盖
- 修复前: 9.5+0.8=10.3GB > 10GB  拒绝
- 修复后: 9.5-1+0.8=9.3GB < 10GB ✓ 允许

影响范围: 本地存储文件上传功能

测试建议:
- 测试文件覆盖场景配额检查正确
- 验证空间使用统计准确
- 确认新文件上传场景仍正常工作

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 14:28:46 +08:00
19174616018andClaude c9553ff497 修复: 添加分享缓存过期机制(TTL)
问题描述:
- 分享文件缓存使用Map没有过期机制
- 缓存会无限增长,占用内存
- 文件更新或删除后缓存不会自动清除
- 长期运行可能导致内存泄漏

修复内容:
1. 实现TTLCache类
   - 支持自定义过期时间(默认1小时)
   - 自动检查缓存是否过期
   - API完全兼容Map(set/get/has/delete)

2. 添加自动清理机制
   - 每10分钟自动清理过期缓存
   - 记录清理日志便于监控
   - 提供destroy方法停止清理定时器

3. 替换shareFileCache为TTLCache实例
   - 设置1小时TTL
   - 无需修改现有代码
   - 向后兼容所有现有使用

缓存特性:
- get()时自动检查过期
- has()时自动检查过期
- 定期清理避免内存积累
- size()方法获取当前缓存数量

影响范围: 分享文件缓存管理

测试建议:
- 验证分享链接访问后缓存生效
- 验证1小时后缓存自动失效
- 监控日志确认定期清理执行

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 14:19:35 +08:00
19174616018andClaude 4a9d31806b 修复: 优化文件上传临时文件清理机制
问题描述:
- 文件删除操作可能抛出异常导致程序中断
- 服务器崩溃或重启后残留临时文件无法清理
- 没有自动清理机制

修复内容:
1. 添加safeDeleteFile()安全删除函数
   - 使用try-catch捕获删除异常
   - 记录删除日志和错误信息
   - 不会因删除失败而中断主流程

2. 添加cleanupOldTempFiles()定期清理函数
   - 启动时自动清理超过24小时的临时文件
   - 防止临时文件堆积占用磁盘空间
   - 容错处理避免清理失败影响启动

3. 替换所有fs.unlinkSync为safeDeleteFile
   - 文件大小超限时的临时文件清理
   - 上传成功后的临时文件清理
   - 上传失败时的临时文件清理

影响范围: 文件上传功能

测试建议:
- 上传文件后检查uploads目录临时文件已删除
- 重启服务器验证旧临时文件自动清理
- 模拟删除失败场景验证不影响主流程

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:46:33 +08:00
19174616018andClaude 3359cb8c01 安全: 为Cookie添加secure和sameSite安全标志
问题描述:
- Cookie仅设置了httpOnly,缺少其他安全标志
- 未启用secure标志,HTTPS环境下cookie可能被劫持
- 缺少sameSite保护,存在CSRF风险

修复内容:
1. 添加secure标志,从环境变量COOKIE_SECURE控制
   - HTTPS环境设置为true
   - HTTP环境设置为false
2. 添加sameSite: 'lax'防止CSRF攻击
   - lax模式在导航时允许cookie
   - 阻止第三方站点的POST请求携带cookie
3. 保留httpOnly: true防止XSS攻击
4. 保留maxAge: 7天的过期时间

配置说明:
- .env中设置 COOKIE_SECURE=true (HTTPS环境)
- .env中设置 COOKIE_SECURE=false (HTTP环境)

影响范围: 用户登录认证cookie安全性

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:36:12 +08:00
19174616018andClaude ee555af1a5 安全: 优化CORS配置,支持环境变量控制允许的来源
问题描述:
- CORS配置使用origin: true允许所有来源
- 无法限制跨域访问,存在CSRF风险
- 生产环境应该只允许特定域名访问

修复内容:
1. 从环境变量ALLOWED_ORIGINS读取允许的来源列表
2. 支持多个域名配置(逗号分隔)
3. 实现origin验证回调函数
4. 默认允许所有(*),但在生产环境会发出警告
5. 记录并拒绝未授权来源的请求

配置示例:
- 开发环境: ALLOWED_ORIGINS=*
- 生产环境: ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com

影响范围: 跨域请求控制

测试建议:
- 配置ALLOWED_ORIGINS后验证只有指定域名可以访问
- 生产环境使用*时应该看到警告日志

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:33:38 +08:00
19174616018andClaude 9d510afa60 安全: 修复密码修改无需验证旧密码的安全漏洞
问题描述:
- 用户修改密码时不需要验证当前密码
- 攻击者获取session后可直接修改密码
- 违反基本的安全最佳实践

修复内容:
1. 添加current_password必填验证
2. 在更新密码前验证当前密码正确性
3. 验证失败返回401错误
4. 更新API文档注释

API变更:
POST /api/user/change-password
请求参数:
- current_password (新增,必填)
- new_password (已有,必填)

影响范围: 用户密码修改功能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:23:18 +08:00
19174616018andClaude a953bda39a 安全: 修复JWT密钥使用默认值的安全隐患
问题描述:
- JWT_SECRET使用硬编码默认值,存在严重安全风险
- 生产环境token可被轻易伪造

修复内容:
1. 在auth.js中添加JWT密钥安全检查
   - 检测默认密钥并发出警告
   - 生产环境强制要求设置JWT_SECRET
2. 更新.env.example添加JWT_SECRET配置说明
   - 提供密钥生成方法
   - 添加其他安全配置项
3. 优化deploy.sh部署脚本
   - 自动生成随机JWT密钥
   - 检测并替换默认密钥

影响范围: 安全认证模块

测试建议:
- 启动服务验证JWT_SECRET警告正常显示
- 使用deploy.sh部署验证自动生成密钥

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:17:57 +08:00
19174616018andClaude d3b9800e35 修复: 分享链接无法访问的问题
- 修复nginx配置中X-Forwarded-Proto使用错误的问题
  - 将 $http_x_forwarded_proto 改为 $scheme
  - 适配IP直接访问的场景
- 添加client_max_body_size 10G 支持大文件上传
- 增加API代理超时时间配置
- 添加favicon.ico避免404错误

修复后分享链接可以正常访问

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 13:08:52 +08:00
WanWanYun d604d8bc78 功能: 更新脚本增加数据库迁移逻辑
- 新增 update_migrate_database() 函数
- 自动检测旧的100MB上传限制并升级到10GB
- 在更新流程中自动执行数据库迁移
- 确保使用更新脚本时数据库配置也会同步更新
- 修复v1.2.5更新后仍保留旧配置的问题

版本: v1.2.6
2025-11-11 02:09:58 +08:00
WanWanYun b10048ec0a 文档: 添加v1.2.5版本更新说明 2025-11-11 01:57:42 +08:00
WanWanYun f097dfd179 修复: 将默认上传限制从100MB提升到10GB
- database.js: 修改数据库初始化默认值为10GB (10737418240字节)
- server.js: 修改两处fallback默认值为10GB
- 确保新部署的系统默认支持10GB单文件上传
- 解决用户报告的100MB上传限制问题

版本: v1.2.5
2025-11-11 01:57:06 +08:00
WanWanYun 8fa338c933 docs: 更新版本历史,添加v1.2.1-v1.2.4的更新说明 2025-11-11 01:51:10 +08:00
WanWanYun 77bb2f7bdc 修复: 管理员账号密码无法正确设置的问题
- 在 server.js 开头添加 require('dotenv').config()
- 确保环境变量从 .env 文件正确加载
- 修复了安装时设置的管理员账号密码不生效的问题

版本: v1.2.4
2025-11-11 01:50:18 +08:00
WanWanYun b13ae7d0dd 工具: 添加数据恢复脚本
功能:
- 自动查找更新时的备份目录
- 显示备份内容和大小
- 恢复数据库、用户文件、配置文件
- 如无备份,提供重新初始化选项
- 自动停止和重启服务

使用场景:
- 更新脚本导致数据丢失时恢复
- 误删数据后恢复
- 从备份恢复系统

使用方法:
curl -fsSL https://gitee.com/yu-yon/vue-driven-cloud-storage/raw/master/restore-data.sh | sudo bash

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 01:08:19 +08:00
WanWanYun 27f130388a 🔥紧急修复: 更新脚本导致数据库丢失的严重Bug v1.2.3
问题描述:
- 更新时 cp -r 命令会覆盖整个 backend 目录
- 导致备份的 data、storage、.env 被新的空文件覆盖
- 用户数据、数据库、配置全部丢失

修复内容:
- 改为分别更新 frontend、upload-tool、backend
- 更新 backend 时逐个文件复制,排除 data/storage/.env/node_modules
- 添加数据完整性检测,自动从备份恢复丢失的文件
- 添加详细的恢复提示信息

安全增强:
- 不再使用 cp -r 整个目录覆盖
- 使用 for 循环逐个文件处理
- 每个重要文件都有检测和恢复机制

影响范围:
- install.sh: update_pull_latest_code() 函数

⚠️ 重要: 已使用旧版更新脚本的用户,数据可能丢失!

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 01:07:12 +08:00
WanWanYun d7ac491ced 工具: 添加Nginx上传限制快速修复脚本
功能:
- 自动查找Nginx配置文件
- 自动备份原配置
- 修改上传大小限制为10GB
- 修改超时时间为1小时
- 测试并重载Nginx配置
- 失败时自动回滚

使用方法:
curl -fsSL https://gitee.com/yu-yon/vue-driven-cloud-storage/raw/master/fix-nginx-upload-limit.sh | sudo bash

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 01:03:35 +08:00
WanWanYun dae4ec9cc5 优化: 提升上传大小限制到10GB并延长超时时间 v1.2.2
修改内容:
- client_max_body_size: 500M → 10G
- proxy_read_timeout: 300s → 3600s (1小时)
- proxy_send_timeout: 300s → 3600s (1小时)
- proxy_connect_timeout: 75s → 300s (5分钟)

适用场景:
- 支持上传大型视频文件
- 支持上传ISO镜像等大文件
- 提供更长的上传时间窗口

影响范围:
- install.sh: configure_nginx_http() 函数
- install.sh: configure_nginx_https() 函数

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 01:02:40 +08:00
WanWanYun 70e0d4ef04 修复: Nginx上传大小限制导致413错误 v1.2.1
问题描述:
- 上传大于6MB的文件时出现413错误
- Nginx默认client_max_body_size为1MB

修复内容:
- 添加 client_max_body_size 500M(支持500MB文件上传)
- 添加上传超时设置(proxy_read_timeout 300s)
- 添加发送超时设置(proxy_send_timeout 300s)
- 添加连接超时设置(proxy_connect_timeout 75s)
- 同时修复HTTP和HTTPS配置

影响范围:
- install.sh: configure_nginx_http() 函数
- install.sh: configure_nginx_https() 函数

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 01:00:51 +08:00
WanWanYun 2f44b98a53 新功能: 一键更新功能 v1.2.0
新增功能:
- 添加一键更新命令(--update 参数)
- 交互式菜单新增「更新/升级」选项
- 支持三种更新方式(curl/wget/本地脚本)

更新流程:
- 智能备份数据库和用户文件到临时目录
- 停止服务并拉取最新代码
- 保留重要文件(data/storage/.env)
- 自动更新后端依赖(npm install)
- 重启服务并进行健康检查
- 显示更新后的版本信息

安全保障:
- 更新前需用户确认
- 自动备份所有用户数据
- 保留配置文件和Nginx配置
- 更新失败时提供清晰的错误提示

文档更新:
- 一键部署命令.txt: 添加更新命令和v1.2.0说明
- INSTALL_GUIDE.md: 详细的更新指南和注意事项

版本: v1.2.0

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 00:57:34 +08:00
WanWanYun 3735a943ff 文档: 更新版本说明至 v1.1.6
- 添加 v1.1.6 更新说明(修复管理员账号密码设置无效问题)
- 完善 v1.1.4 和 v1.1.5 版本描述
- 标注严重级别问题修复

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 00:28:40 +08:00
WanWanYun 4e9a3a5d26 修复: 管理员账号密码从环境变量读取
问题描述:
- 安装时设置的管理员账号密码无效
- 始终使用硬编码的 admin/admin123

修复内容:
- createDefaultAdmin() 函数现在从环境变量读取
- 使用 process.env.ADMIN_USERNAME 和 ADMIN_PASSWORD
- 保留默认值作为后备方案

影响范围:
- backend/database.js: createDefaultAdmin() 函数 (lines 130-159)

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 00:27:54 +08:00
WanWanYun 2213547e3d v1.1.5 修复: 支持管道执行时的模式选择
问题修复:
- 修复 curl | bash 执行时无法显示交互选择的问题
- 添加终端检测 (使用 -t 0 和 /dev/tty 检测)
- 管道执行时显示友好提示并自动进入安装模式
- 提示用户下载脚本后运行以使用交互选择

改进说明:
- 交互式终端: 显示选择界面 [1] 安装 [2] 卸载
- 管道执行: 显示提示信息并默认安装
- 提供卸载命令说明

技术细节:
- 使用 [[ -t 0 ]] 检测标准输入是否是终端
- 使用 [[ -c /dev/tty ]] 检测 /dev/tty 可用性
- 两个条件任一满足即可使用交互模式
2025-11-11 00:16:25 +08:00
WanWanYun 94cff001d8 v1.1.5: 添加交互式安装/卸载模式选择
主要更新:
- 在脚本启动时添加交互式选择界面
- 用户可以选择: [1] 安装/部署 或 [2] 卸载
- 保留原有命令行参数方式 (--uninstall)
- 改进用户体验,更直观易用

功能说明:
- 运行脚本后首先显示选择界面
- 选择安装模式 -> 继续安装流程
- 选择卸载模式 -> 直接跳转到卸载流程
- 通过命令行参数依然可以直接指定模式

使用方式:
1. 交互式: bash install.sh (显示选择菜单)
2. 命令行: bash install.sh --uninstall (直接卸载)
2025-11-11 00:05:19 +08:00
WanWanYun 84d2feffa2 更新版本号至 v1.1.4 2025-11-10 23:57:39 +08:00
WanWanYun 49e0f53cce v1.1.4: 添加智能端口检测和自定义配置
主要更新:
- 自动检测端口占用(80, 443, 40001)
- 支持自定义HTTP/HTTPS/后端端口
- 端口冲突时智能提示并允许自定义
- 所有配置(Nginx、.env)自动使用自定义端口
- 完成提示中显示实际使用的端口
- 健康检查使用动态端口验证

功能特性:
- HTTP端口(默认80,可自定义如8080)
- HTTPS端口(默认443,可自定义如8443)
- 后端端口(默认40001,可自定义如40002)
- 使用netstat/ss检测端口占用
- 端口范围验证(1024-65535)
- 避免与现有项目冲突

更新内容:
- 全局变量: HTTP_PORT, HTTPS_PORT, BACKEND_PORT
- 新增: check_port_available() 检测端口
- 新增: configure_ports() 配置端口
- 修改: configure_nginx_http() 使用自定义端口
- 修改: configure_nginx_https() 使用自定义端口
- 修改: create_env_file() PORT使用BACKEND_PORT
- 修改: health_check() 检查自定义后端端口
- 修改: print_completion() 显示端口信息
- 新增: 一键部署命令.txt 到仓库
2025-11-10 23:57:16 +08:00
WanWanYun 4a38026c07 文档更新: 添加v1.1.3卸载功能详细说明
更新内容:
- INSTALL_GUIDE.md: 完善FAQ第6项卸载说明
- 添加一键卸载命令(3种方式)
- 详细说明卸载特性:双重确认、数据备份、完整清理
- 说明保留系统环境,仅删除项目文件
- 添加智能残留检查说明
2025-11-10 23:49:51 +08:00
WanWanYun 9966719afa v1.1.3: 集成卸载功能到主脚本
主要更新:
- 添加卸载模式: bash install.sh --uninstall
- 支持参数: --uninstall, -u, uninstall
- 双重确认机制防止误删
- 可选数据备份到 /root/wanwanyun-backup-*
- 完整清理: PM2进程、Nginx配置、SSL证书、项目目录
- 保留环境: Node.js、Nginx、PM2、编译工具
- 残留检查确保清理完整

使用方法:
- 安装: bash install.sh
- 卸载: bash install.sh --uninstall

功能特性:
- 智能备份询问
- 逐步状态显示
- 清理完成报告
- 环境保留列表
2025-11-10 23:48:32 +08:00
WanWanYun 8c7664a400 v1.1.2: 修复依赖安装问题
主要修复:
1. 降级 better-sqlite3: 12.4.1 → 11.8.1
   - 兼容 Node.js 18.x
   - 解决版本不匹配导致的安装失败

2. 添加编译工具依赖
   - APT: build-essential, python3
   - YUM/DNF: gcc-c++, make, python3
   - Zypper: gcc-c++, make, python3
   - 解决原生模块编译失败问题

问题分析:
- better-sqlite3@12+ 要求 Node.js 20+
- 编译原生模块需要 C++ 编译器和 Python
- 旧版本服务器可能缺少编译工具链

解决方案:
- 使用兼容 Node.js 18+ 的 better-sqlite3 版本
- 自动安装所有必需的编译工具
- 确保在所有支持的系统上都能正常编译
2025-11-10 23:35:33 +08:00
WanWanYun 1eb619a0b6 v1.1.1: 升级到 Node.js 20.x LTS
主要更新:
- 升级默认Node.js版本从18.x到20.x (LTS长期支持)
- 解决 NodeSource 弃用警告问题
- 更新所有Node.js版本检查逻辑使用变量而非硬编码
- 提高代码可维护性和未来兼容性

技术改进:
- NODE_VERSION: 18 → 20
- install_nodejs_apt/yum/dnf/zypper: 使用 $NODE_VERSION 变量检查
- 确保更长的官方支持周期(至2026年4月)

支持周期:
- Node.js 18.x: 支持到 2025-04-30
- Node.js 20.x: 支持到 2026-04-30 
2025-11-10 23:29:43 +08:00
WanWanYun 47f04f4cb9 v1.1.0: 新增多系统支持和统一包管理器
主要更新:
- 新增支持 Rocky Linux、AlmaLinux、Fedora、openSUSE
- 实现统一包管理器检测机制 (APT/YUM/DNF/Zypper)
- 优化系统识别逻辑,增加自动检测后备方案
- 完善阿里云镜像源配置,支持更多Linux发行版
- 重构所有系统相关代码,使用PKG_MANAGER变量统一管理
- 添加 install_nodejs_dnf/zypper 和 install_nginx_dnf/zypper 函数
- 更新 deploy_certbot 函数支持所有包管理器

技术改进:
- detect_os(): 增强系统检测,自动识别包管理器类型
- configure_aliyun_mirror(): 支持8种主流Linux发行版镜像配置
- install_dependencies(): 统一使用PKG_MANAGER进行依赖安装
- 消除所有OS特定case语句,提高代码可维护性

支持的系统:
- Ubuntu/Debian (APT)
- CentOS 7/8, RHEL 7/8/9 (YUM/DNF)
- Rocky Linux 8/9 (DNF)
- AlmaLinux 8/9 (DNF)
- Fedora 35+ (DNF)
- openSUSE Leap (Zypper)
2025-11-10 23:25:44 +08:00
WanWanYun 650b926a0b 修复: Debian系统阿里云镜像源配置错误
- 分离Ubuntu和Debian的源配置逻辑
- Debian使用正确的组件: main, contrib, non-free, non-free-firmware
- Debian安全源使用debian-security仓库
- Ubuntu继续使用: main, restricted, universe, multiverse
- 修复apt更新时的组件缺失警告
2025-11-10 23:13:13 +08:00
WanWanYun 20d852027f 修复v2: 改进stdin重定向方式
- 移除全局 exec < /dev/tty,避免管道执行中断
- 在每个read命令中单独指定 < /dev/tty
- 兼容 curl|bash 和 bash <(...) 两种执行方式
- 版本号 v1.0.2
2025-11-10 23:05:01 +08:00
WanWanYun 9f2ae24772 修复: 解决管道执行脚本时无法读取用户输入的问题
- 添加 exec < /dev/tty 重定向标准输入到终端
- 修复 curl|bash 执行时 read 命令失效的问题
- 版本号更新为 v1.0.1
2025-11-10 23:01:57 +08:00
WanWanYun c8b8026d43 添加一键部署使用指南 2025-11-10 22:56:57 +08:00
WanWanYun 4341e82c44 添加一键部署脚本
- 支持Ubuntu/Debian/CentOS系统自动检测
- 自动安装Node.js、Nginx、PM2等依赖
- 提供官方源和阿里云镜像源选择
- 支持域名/IP两种访问模式
- 6种SSL自动部署方案(Certbot、acme.sh等)
- 智能容错和重试机制
- 全程自动化部署,用户仅需选择和输入
2025-11-10 22:55:51 +08:00
WanWanYun 0f133962dc Initial commit - 玩玩云文件管理系统 v1.0.0
- 完整的前后端代码
- 支持本地存储和SFTP存储
- 文件分享功能
- 上传工具源代码
- 完整的部署文档
- Nginx配置模板

技术栈:
- 后端: Node.js + Express + SQLite
- 前端: Vue.js 3 + Axios
- 存储: 本地存储 / SFTP远程存储
2025-11-10 21:50:16 +08:00
156 changed files with 46991 additions and 4853 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Build and tests
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: |
backend/package-lock.json
frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Audit production dependencies
run: npm audit --omit=dev
- name: Run backend tests
run: npm test
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Audit frontend dependencies
working-directory: frontend
run: npm audit
- name: Build frontend
working-directory: frontend
run: npm run build
- name: Validate deployment script syntax
run: bash -n ../install.sh
+12 -11
View File
@@ -1,5 +1,6 @@
# 依赖
node_modules/
frontend/dist/
__pycache__/
*.pyc
*.pyo
@@ -15,9 +16,11 @@ __pycache__/
# 临时文件
backend/uploads/
backend/storage/ # 本地存储数据
# 本地存储数据目录(保留 .gitkeep
backend/storage/*
!backend/storage/.gitkeep
backend/data/ # 数据库目录
# 数据库目录(保留 .gitkeep
backend/data/*
!backend/data/.gitkeep
*.log
.DS_Store
@@ -87,9 +90,7 @@ package-lock.json.bak
# Claude配置
.claude/
# 测试脚本和报告
backend/test-*.js
backend/verify-*.js
# 测试报告和本地验证产物
backend/verify-*.sh
backend/test-results-*.json
backend/*最终*.js
@@ -128,9 +129,9 @@ backend/fix-env.js
backend/create-admin.js
backend/*.backup.*
# 维护和调试脚本
backend/check-*.js
backend/cleanup-*.js
backend/rebuild-*.js
backend/update-*.js
backend/upgrade-*.js
# 本地协作与设计验收产物
/work/
/desktop-client/design-qa.md
# 桌面安装包通过 Gitea Release 发布,Git 仅保存校验元数据
/frontend/downloads/*.exe
+1
View File
@@ -0,0 +1 @@
20.19.0
+37
View File
@@ -0,0 +1,37 @@
# 贡献与工程约束
## 本地验证
项目要求 Node.js `20.19``24.x`。后端与 Web 前端分别维护锁文件,依赖变更后必须同步提交对应的 `package-lock.json`
```bash
cd backend
npm ci
npm test
npm audit --omit=dev
cd ../frontend
npm ci
npm run build
npm audit
```
开发 Web 前端时分别运行后端 `npm run dev` 与前端 `npm run dev`。生产环境只服务 `frontend/dist/`,不要手工修改构建产物或给资源 URL 追加版本号。
## 不可破坏的约束
1. `app.use(expressErrorHandler)` 必须保持为最后一个 `app.use`CI 的架构测试会检查这一点。
2. Express 4 使用 `express-async-errors` 捕获异步路由拒绝;该补丁必须在注册任何路由前加载。
3. 限流器、下载安全计数和 OSS 用量缓存仍是进程内状态,PM2 必须保持单实例 `fork` 模式。扩容前先迁移到共享状态存储。
4. `npm ci` 依赖严格同步的 lock 文件;禁止在部署脚本中删除 `package-lock.json`
5. Express 中间件顺序是安全边界:requestId/安全头/CORS/HTTPS/静态资源/API 限流/请求体/CSRF,不得无验证调整。
6. 桌面安装包目录 `frontend/downloads/` 是运行时发布目录,更新前必须备份且不得放进 `frontend/dist/` 的清理生命周期。
7. 桌面安装包必须作为 Gitea Release 附件发布;Git 只提交 `frontend/desktop-release.json`,禁止提交 `.exe`
8. 更新 Release 元数据时必须核对标签、文件名、字节数和 SHA256,并运行 `npm run check:desktop-release`
## 改动原则
- 路由继续按业务域渐进迁移,每次只迁移一个域并运行全部测试。
- 新抽取的纯函数必须直接测试生产模块;禁止在测试文件复制实现。
- 安全或配额核心逻辑应做一次变异验证:临时改坏生产实现,确认对应测试失败后立即还原。
- 日志使用 `req.log` 或公共 logger,禁止记录密码、令牌、Cookie、Access Key 或完整授权头。
+26 -14
View File
@@ -10,7 +10,7 @@
- **磁盘空间**: 至少 2GB 可用空间
### 软件依赖
- **Node.js**: 20.x LTS
- **Node.js**: 20.19+(支持 20.19-24
- **Nginx**: 1.18+
- **Git**: 2.x
@@ -27,7 +27,7 @@ curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
# 验证安装
node -v # 应显示 v20.x.x
node -v # 应显示 v20.19.0 或更高版本
npm -v
```
@@ -81,13 +81,26 @@ sudo chown -R $USER:$USER /var/www/wanwanyun
```bash
cd /var/www/wanwanyun/backend
# 安装依赖
npm install --production
# 严格按锁文件安装生产依赖
npm ci --omit=dev
# 创建数据目录
mkdir -p data storage
```
### 4.1 构建 Web 前端
```bash
cd /var/www/wanwanyun/frontend
# Vite 是构建依赖,生产构建时也必须安装 devDependencies
npm ci --include=dev
npm run build
# Nginx 只需要 dist,可按需释放依赖占用
rm -rf node_modules
```
### 5. 配置环境变量
```bash
@@ -115,7 +128,8 @@ ADMIN_PASSWORD=你的强密码
sudo cp /var/www/wanwanyun/nginx/nginx.conf /etc/nginx/sites-available/wanwanyun
# 修改配置中的路径
sudo sed -i 's|/usr/share/nginx/html|/var/www/wanwanyun/frontend|g' /etc/nginx/sites-available/wanwanyun
sudo sed -i 's|/usr/share/nginx/html|/var/www/wanwanyun/frontend/dist|g' /etc/nginx/sites-available/wanwanyun
sudo sed -i 's|/runtime/downloads|/var/www/wanwanyun/frontend/downloads|g' /etc/nginx/sites-available/wanwanyun
sudo sed -i 's|backend:40001|127.0.0.1:40001|g' /etc/nginx/sites-available/wanwanyun
# 创建软链接启用配置
@@ -133,6 +147,8 @@ sudo systemctl reload nginx
### 7. 配置系统服务
> 后端当前使用进程内限流器与用量缓存,只能运行一个实例。不要使用 PM2 cluster 模式或同时启动多个 systemd 实例。
创建 systemd 服务文件:
```bash
@@ -242,7 +258,8 @@ sudo tail -f /var/log/nginx/error.log
```bash
cd /var/www/wanwanyun
sudo git pull
cd backend && npm install --production
cd backend && npm ci --omit=dev
cd ../frontend && npm ci --include=dev && npm run build && rm -rf node_modules
sudo systemctl restart wanwanyun
```
@@ -292,14 +309,9 @@ sudo chown -R www-data:www-data /var/www/wanwanyun/backend/data/
## 性能优化
### 启用 Nginx 缓存
在 Nginx 配置的 `location /` 中添加:
```nginx
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
```
### Nginx 静态缓存
仓库配置已经对 Vite 哈希资源 `/assets/` 设置一年缓存,对兼容库 `/libs/` 设置 30 天缓存。不要在子 `location` 中单独使用 `add_header Cache-Control`,否则会覆盖上层继承的安全响应头。
### 配置日志轮转
```bash
+69 -38
View File
@@ -6,7 +6,7 @@
![Version](https://img.shields.io/badge/version-3.1.0-blue.svg)
![License](https://img.shields.io/badge/license-Personal%20Use-green.svg)
![Node](https://img.shields.io/badge/node-20.x-brightgreen.svg)
![Node](https://img.shields.io/badge/node-20.19%2B-brightgreen.svg)
![Vue](https://img.shields.io/badge/vue-3.x-42b883.svg)
</div>
@@ -54,11 +54,11 @@
- 支持SMTP配置
- 邮件模板可自定义
#### 🖥️ 桌面上传工具
- 拖拽上传,简单易用
- 实时显示上传进度
- 自动配置,无需手动设置
- 支持大文件上传
#### 🖥️ 桌面客户端
- Tauri + Vue 桌面端,默认对接网页端账号体系
- 支持文件浏览、搜索、上传、下载、分享/直链管理
- 支持断点下载、本地目录同步和客户端更新检测
- Windows 安装包可通过 `/api/client/desktop-update` 发布和下载
#### ⚡ 一键部署
- 全自动安装脚本(install.sh
@@ -72,8 +72,10 @@
### 环境要求
- **操作系统**: Linux (Ubuntu 18.04+ / Debian 10+ / CentOS 7+)
- **Node.js**: 20.19+(支持 20.19-24,推荐与 `.nvmrc` 保持一致)
- **内存**: 最低 1GB RAM(推荐 2GB+
- **磁盘空间**: 至少 2GB 可用空间
- **后端实例数**: 当前限流与用量缓存为进程内状态,PM2 必须保持单实例 `fork` 模式
### 方式1: 一键部署(推荐)⭐
@@ -123,10 +125,10 @@ docker-compose logs -f
部署完成后,访问系统:
- **访问地址**: http://你的服务器IP
- **默认管理员账号**:
- 用户名: `admin`
- 密码: `admin123`
- ⚠️ **请立即登录并修改密码!**
- **管理员账号**:
- 安装脚本会要求你设置管理员用户名和强密码
- 密码至少 8 位,并且需要包含字母、数字、特殊字符中的至少两类
- 生产环境禁止使用 `admin123``12345678` 等默认弱密码
## 📖 使用指南
@@ -210,12 +212,21 @@ SMTP密码: 你的授权码
3. 复制分享链接发送给他人
4. 在"我的分享"中管理所有分享链接
### 使用桌面上传工具
### 使用桌面客户端
1. 进入"上传工具"页面
2. 下载适合你系统的上传工具
3. 输入服务器地址和API密钥
4. 拖拽文件即可上传
1. 在登录页点击"下载桌面客户端",或访问后端更新接口获取安装包信息
2. 安装后使用网页端账号登录
3. 在客户端中浏览文件、上传/下载、创建分享或直链
4. 桌面安装包由 Gitea Release 发布,部署脚本会按 `frontend/desktop-release.json` 下载并校验 SHA256
发布新桌面版本时,先创建对应的 `vX.Y.Z` Release 并上传安装包,再更新
`frontend/desktop-release.json` 的版本、文件名、大小和 SHA256。不要把 `.exe`
提交到 Git。可用以下命令同步当前版本:
```bash
cd frontend
npm run sync:desktop-release
```
## 📁 项目结构
@@ -232,23 +243,25 @@ vue-driven-cloud-storage/
│ ├── data/ # 数据库目录
│ └── storage/ # 本地存储目录
├── frontend/ # 前端代码
├── frontend/ # Vite 多页面 Web 前端
│ ├── index.html # 登录注册页面
│ ├── app.html # 主应用页面
│ ├── share.html # 分享页面
│ ├── verify.html # 邮箱验证页面
│ ├── reset-password.html # 密码重置页面
── libs/ # 第三方库 (Vue.js, Axios, FontAwesome)
── package.json # Vue/Axios/Vite 依赖
│ ├── vite.config.js # 多页面生产构建
│ ├── dist/ # 构建产物(不入库)
│ └── libs/ # 分享页兼容静态资源与 Font Awesome
├── nginx/ # Nginx 配置
│ ├── nginx.conf # 反向代理配置
│ └── nginx.conf.example # 配置模板
├── upload-tool/ # 桌面上传工具
│ ├── upload_tool.py # Python 上传工具源码
│ ├── requirements.txt # Python 依赖
── build.bat # Windows 打包脚本
│ └── build.sh # Linux/Mac 打包脚本
├── desktop-client/ # Tauri 桌面客户端
│ ├── src/ # Vue/TypeScript 前端
│ ├── src-tauri/ # Rust/Tauri 原生能力
── package.json # 桌面端依赖和构建脚本
├── install.sh # 一键安装脚本
├── docker-compose.yml # Docker 编排文件
@@ -267,11 +280,11 @@ vue-driven-cloud-storage/
- **bcrypt** - 密码加密
- **nodemailer** - 邮件发送
- **svg-captcha** - 验证码生成
- **express-session** - Session 管理
### 前端技术
- **Vue.js 3** - 渐进式 JavaScript 框架
- **Axios** - HTTP 请求库
- **Vite 8** - 多页面构建、依赖打包与哈希缓存
- **Font Awesome** - 图标库
- **原生 CSS** - 现代化界面设计
@@ -286,7 +299,7 @@ vue-driven-cloud-storage/
### 认证与授权
- ✅ bcrypt 密码加密(10轮盐值)
- ✅ JWT 令牌认证
-Session 安全管理
-HttpOnly Cookie + CSRF 双提交令牌
- ✅ CORS 跨域配置
- ✅ SQL 注入防护(参数化查询)
- ✅ XSS 防护(输入过滤)
@@ -310,7 +323,7 @@ vue-driven-cloud-storage/
```bash
# Systemd 部署
sudo systemctl status vue-cloud-storage
sudo systemctl status wanwanyun
# Docker 部署
docker-compose ps
@@ -320,7 +333,7 @@ docker-compose ps
```bash
# Systemd 部署
sudo journalctl -u vue-cloud-storage -f
sudo journalctl -u wanwanyun -f
# Docker 部署
docker-compose logs -f backend
@@ -330,7 +343,7 @@ docker-compose logs -f backend
```bash
# Systemd 部署
sudo systemctl restart vue-cloud-storage
sudo systemctl restart wanwanyun
# Docker 部署
docker-compose restart
@@ -340,23 +353,28 @@ docker-compose restart
```bash
# 备份数据库
sudo cp /var/www/vue-driven-cloud-storage/backend/data/database.db \
sudo cp /var/www/wanwanyun/backend/data/database.db \
/backup/database.db.$(date +%Y%m%d)
# 备份上传文件(本地存储模式)
sudo tar -czf /backup/uploads-$(date +%Y%m%d).tar.gz \
/var/www/vue-driven-cloud-storage/backend/uploads/
/var/www/wanwanyun/backend/storage/
```
### 更新系统
```bash
cd /var/www/vue-driven-cloud-storage
cd /var/www/wanwanyun
git pull
cd backend && npm install
sudo systemctl restart vue-cloud-storage
cd backend && npm ci --omit=dev
cd ../frontend && npm ci --include=dev && npm run build
sudo systemctl restart wanwanyun
```
`install.sh --update` 会在替换线上文件前同步并校验 Gitea Release、构建前端,
同时保留 `frontend/downloads/` 中已验证的运行时安装包。完整工程约束见
[CONTRIBUTING.md](./CONTRIBUTING.md)。
## 📊 性能优化建议
### 生产环境配置
@@ -396,12 +414,12 @@ A: 登录后进入"管理面板" → "存储管理",点击切换按钮即可
A: 点击登录页的"忘记密码",通过邮箱重置密码。如未配置邮箱,需要手动重置数据库。
**Q: 上传文件大小限制是多少?**
A: 默认限制 5GB,可在 Nginx 配置中修改 `client_max_body_size`
A: 当前默认限制 10GB,可在系统设置和 Nginx 配置中同步调整
### 故障排查
**Q: 无法访问系统**
1. 检查服务是否启动:`sudo systemctl status vue-cloud-storage`
1. 检查服务是否启动:`sudo systemctl status wanwanyun`
2. 检查防火墙是否开放端口
3. 查看 Nginx 日志:`sudo tail -f /var/log/nginx/error.log`
@@ -424,6 +442,16 @@ A: 默认限制 5GB,可在 Nginx 配置中修改 `client_max_body_size`。
## 📝 更新日志
### v3.1.1 (2026-06-13)
- 🔐 统一文件虚拟路径校验,修复列表、上传、下载、分享相关路径边界问题
- 🔐 上传参数校验失败时清理临时文件,避免失败上传残留
- 🔐 密码策略统一为 8-128 字符且至少两类字符,安装脚本和前端提示同步更新
- 🔐 邮箱验证和重置密码链接使用后清理 URL token
- 🔐 分享页外部下载窗口增加 `noopener,noreferrer`
- 🧪 新增隔离 API 审计回归脚本,覆盖登录、CSRF、文件、分享、直链和管理端脱敏
- 🛠️ 清理已废弃的 `SESSION_SECRET` / `express-session` 文档和安装脚本残留
- 🖥️ 明确桌面客户端更新接口和 Windows 安装包发布流程
### v3.1.0 (2025-01-18)
- 🚀 **重大架构优化**OSS 直连上传下载(不经过后端)
- 上传速度提升 50%,服务器流量节省 50%
@@ -474,11 +502,14 @@ A: 默认限制 5GB,可在 Nginx 配置中修改 `client_max_body_size`。
git clone https://git.workyai.cn/237899745/vue-driven-cloud-storage.git
cd vue-driven-cloud-storage
# 安装依赖
cd backend && npm install
# 安装并测试后端
cd backend && npm ci && npm test
# 启动开发服务器
node server.js
# 终端一:启动后端
npm run dev
# 终端二:启动 Vite(项目根目录执行)
cd ../frontend && npm ci && npm run dev
```
### 提交规范
+9 -6
View File
@@ -1,14 +1,15 @@
# 依赖目录
node_modules
# 数据目录
data/
storage/
# 数据目录(仅保留占位文件)
data/*
!data/.gitkeep
storage/*
!storage/.gitkeep
# 环境配置
.env
.env.local
.env.*.local
.env.*
# 日志
*.log
@@ -30,6 +31,7 @@ Thumbs.db
*.spec.js
test/
tests/
test_*.js
coverage/
# 文档
@@ -40,7 +42,8 @@ coverage/
.git
.gitignore
# 临时文件
# 临时和平台脚本
*.tmp
*.temp
.cache/
*.bat
+30 -16
View File
@@ -20,7 +20,15 @@ NODE_ENV=production
# 强制HTTPS访问(生产环境建议开启)
# 设置为 true 时,仅接受 HTTPS 访问
ENFORCE_HTTPS=false
ENFORCE_HTTPS=true
# 公开访问地址(生产环境必须配置,用于邮件、分享、直链等外部链接)
# 示例: PUBLIC_BASE_URL=https://cs.workyai.cn
PUBLIC_BASE_URL=https://your-domain.example
# Host 白名单(可选;未配置 PUBLIC_BASE_URL 时生产环境必须配置)
# 示例: ALLOWED_HOSTS=cs.workyai.cn
ALLOWED_HOSTS=your-domain.example
# 公开访问端口(nginx监听的端口,用于生成分享链接)
# 标准端口(80/443)可不配置
@@ -33,20 +41,20 @@ PUBLIC_PORT=80
# 加密密钥(必须配置!)
# 用于加密 OSS Access Key Secret 等敏感数据
# 生成方法: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY=your-encryption-key-please-change-this
ENCRYPTION_KEY=REPLACE_WITH_64_HEX_CHARACTERS_GENERATED_BY_COMMAND
# JWT密钥(必须修改!)
# 生成方法: openssl rand -base64 32
# 或使用: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
JWT_SECRET=your-secret-key-PLEASE-CHANGE-THIS-IN-PRODUCTION
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_AT_LEAST_32_CHARS
# Refresh Token 密钥(可选,默认使用 JWT_SECRET 派生)
# 建议生产环境设置独立的密钥
# REFRESH_SECRET=your-refresh-secret-key
# REFRESH_SECRET=REPLACE_WITH_SEPARATE_RANDOM_REFRESH_SECRET
# 管理员账号配置(首次启动时创建)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ADMIN_PASSWORD=REPLACE_WITH_STRONG_ADMIN_PASSWORD
# ============================================
# CORS 跨域配置(重要!)
@@ -69,17 +77,24 @@ ADMIN_PASSWORD=admin123
# ALLOWED_ORIGINS=https://pan.example.com,https://admin.example.com
# ALLOWED_ORIGINS=http://localhost:8080 # 开发环境
#
ALLOWED_ORIGINS=
ALLOWED_ORIGINS=https://your-domain.example
# Cookie 安全配置
# 使用 HTTPS 时必须设置为 true
# HTTP 环境设置为 false
COOKIE_SECURE=false
COOKIE_SECURE=true
# CSRF 防护配置
# 启用 CSRF 保护(建议生产环境开启)
# 前端会自动从 Cookie 读取 csrf_token 并在请求头中发送
ENABLE_CSRF=false
ENABLE_CSRF=true
# 全局 API IP 限流(现有登录/分享细粒度限流之外的连接兜底)
API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=600
# 健康检查判定磁盘空间不足的阈值(默认 256MB)
HEALTH_MIN_DISK_FREE_BYTES=268435456
# ============================================
# 反向代理配置(Nginx/Cloudflare等)
@@ -97,7 +112,7 @@ ENABLE_CSRF=false
# ⚠️ 重要: 如果使用 Nginx 反向代理并开启 ENFORCE_HTTPS=true
# 必须配置 TRUST_PROXY=1,否则后端无法正确识别HTTPS请求
#
TRUST_PROXY=false
TRUST_PROXY=1
# ============================================
# 存储配置
@@ -126,15 +141,11 @@ STORAGE_ROOT=./storage
# OSS_ENDPOINT= # 自定义 Endpoint(可选)
# ============================================
# Session 配置
# 验证码票据配置
# ============================================
# Session 密钥(用于验证码等功能
# 默认使用随机生成的密钥
# SESSION_SECRET=your-session-secret
# Session 过期时间(毫秒),默认 30 分钟
# SESSION_MAX_AGE=1800000
# 验证码票据签名密钥(可选;默认复用 JWT_SECRET
# CAPTCHA_SECRET=replace-with-random-32-byte-hex
# ============================================
# 开发调试配置
@@ -143,6 +154,9 @@ STORAGE_ROOT=./storage
# 日志级别 (error, warn, info, debug)
# LOG_LEVEL=info
# production 默认输出 JSON;开发环境可显式开启
# LOG_FORMAT=json
# 是否启用调试模式
# DEBUG=false
+9 -10
View File
@@ -1,28 +1,27 @@
FROM node:20-alpine
FROM node:20-alpine AS dependencies
WORKDIR /app
# 安装编译工具和健康检查所需的 wget
# 原生依赖使用镜像内置 Node headers,避免构建时依赖外部 headers 下载站点。
RUN apk add --no-cache python3 make g++ wget
ENV npm_config_nodedir=/usr/local
# 复制 package 文件
COPY package*.json ./
RUN npm ci --omit=dev
# 安装依赖
RUN npm install --production
FROM node:20-alpine AS runtime
# 复制应用代码
WORKDIR /app
RUN apk add --no-cache libstdc++ wget
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
# 创建数据目录
RUN mkdir -p /app/data /app/storage
# 暴露端口
EXPOSE 40001
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --spider -q http://localhost:40001/api/health || exit 1
# 启动应用
CMD ["node", "server.js"]
+126 -13
View File
@@ -1,7 +1,26 @@
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { UserDB } = require('./database');
const { UserDB, DeviceSessionDB, SystemLogDB } = require('./database');
const { decryptSecret } = require('./utils/encryption');
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
function normalizeDownloadTrafficQuotaForAuth(rawQuota) {
if (rawQuota === null || rawQuota === undefined || rawQuota === '') {
return -1; // 未设置时默认不限流量
}
const parsedQuota = Number(rawQuota);
if (!Number.isFinite(parsedQuota)) {
return -1;
}
if (parsedQuota < 0) {
return -1; // -1 表示不限流量
}
return Math.max(0, Math.floor(parsedQuota)); // 0 表示禁止下载
}
// JWT密钥(必须在环境变量中设置)
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
@@ -15,7 +34,8 @@ const REFRESH_TOKEN_EXPIRES = '7d'; // Refresh token 7天
// 安全检查:验证JWT密钥配置
const DEFAULT_SECRETS = [
'your-secret-key-change-in-production',
'your-secret-key-change-in-production-PLEASE-CHANGE-THIS'
'your-secret-key-change-in-production-PLEASE-CHANGE-THIS',
'your-secret-key-PLEASE-CHANGE-THIS-IN-PRODUCTION'
];
// 安全修复:增强 JWT_SECRET 验证逻辑
@@ -62,13 +82,16 @@ if (JWT_SECRET.length < 32) {
console.log('[安全] ✓ JWT密钥验证通过');
// 生成Access Token(短期)
function generateToken(user) {
function generateToken(user, sessionId = null) {
const safeSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
return jwt.sign(
{
id: user.id,
username: user.username,
is_admin: user.is_admin,
type: 'access'
type: 'access',
sid: safeSessionId,
jti: crypto.randomBytes(12).toString('hex')
},
JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRES }
@@ -76,11 +99,13 @@ function generateToken(user) {
}
// 生成Refresh Token(长期)
function generateRefreshToken(user) {
function generateRefreshToken(user, sessionId = null) {
const safeSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
return jwt.sign(
{
id: user.id,
type: 'refresh',
sid: safeSessionId,
// 添加随机标识,使每次生成的refresh token不同
jti: crypto.randomBytes(16).toString('hex')
},
@@ -89,8 +114,26 @@ function generateRefreshToken(user) {
);
}
function decodeAccessToken(token) {
if (!token || typeof token !== 'string') return null;
try {
return jwt.verify(token, JWT_SECRET);
} catch {
return null;
}
}
function decodeRefreshToken(token) {
if (!token || typeof token !== 'string') return null;
try {
return jwt.verify(token, REFRESH_SECRET);
} catch {
return null;
}
}
// 验证Refresh Token并返回新的Access Token
function refreshAccessToken(refreshToken) {
function refreshAccessToken(refreshToken, context = {}) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
@@ -98,6 +141,18 @@ function refreshAccessToken(refreshToken) {
return { success: false, message: '无效的刷新令牌类型' };
}
const sessionId = typeof decoded.sid === 'string' ? decoded.sid.trim() : '';
if (sessionId) {
const activeSession = DeviceSessionDB.findActiveBySessionId(sessionId);
if (!activeSession || Number(activeSession.user_id) !== Number(decoded.id)) {
return { success: false, message: '当前设备会话已失效,请重新登录' };
}
DeviceSessionDB.touch(sessionId, {
ipAddress: context.ipAddress,
userAgent: context.userAgent
});
}
const user = UserDB.findById(decoded.id);
if (!user) {
@@ -113,11 +168,12 @@ function refreshAccessToken(refreshToken) {
}
// 生成新的access token
const newAccessToken = generateToken(user);
const newAccessToken = generateToken(user, sessionId || null);
return {
success: true,
token: newAccessToken,
sessionId: sessionId || null,
user: {
id: user.id,
username: user.username,
@@ -146,6 +202,29 @@ function authMiddleware(req, res, next) {
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.type !== 'access') {
return res.status(401).json({
success: false,
message: '无效的令牌类型'
});
}
const sessionId = typeof decoded.sid === 'string' ? decoded.sid.trim() : '';
if (sessionId) {
const activeSession = DeviceSessionDB.findActiveBySessionId(sessionId);
if (!activeSession || Number(activeSession.user_id) !== Number(decoded.id)) {
return res.status(401).json({
success: false,
message: '当前设备已下线,请重新登录'
});
}
DeviceSessionDB.touch(sessionId, {
ipAddress: req.ip || req.socket?.remoteAddress || '',
userAgent: req.get('user-agent') || ''
});
}
const user = UserDB.findById(decoded.id);
if (!user) {
@@ -169,6 +248,19 @@ function authMiddleware(req, res, next) {
});
}
const rawOssQuota = Number(user.oss_storage_quota);
const effectiveOssQuota = Number.isFinite(rawOssQuota) && rawOssQuota > 0
? rawOssQuota
: DEFAULT_OSS_STORAGE_QUOTA_BYTES;
const effectiveDownloadTrafficQuota = normalizeDownloadTrafficQuotaForAuth(user.download_traffic_quota);
const rawDownloadTrafficUsed = Number(user.download_traffic_used);
const normalizedDownloadTrafficUsed = Number.isFinite(rawDownloadTrafficUsed) && rawDownloadTrafficUsed > 0
? Math.floor(rawDownloadTrafficUsed)
: 0;
const cappedDownloadTrafficUsed = effectiveDownloadTrafficQuota > 0
? Math.min(normalizedDownloadTrafficUsed, effectiveDownloadTrafficQuota)
: normalizedDownloadTrafficUsed;
// 将用户信息附加到请求对象(包含所有存储相关字段)
req.user = {
id: user.id,
@@ -180,18 +272,31 @@ function authMiddleware(req, res, next) {
oss_provider: user.oss_provider,
oss_region: user.oss_region,
oss_access_key_id: user.oss_access_key_id,
// 安全修复:解密 OSS Access Key Secret(如果存在)
oss_access_key_secret: user.oss_access_key_secret ? decryptSecret(user.oss_access_key_secret) : null,
oss_bucket: user.oss_bucket,
oss_endpoint: user.oss_endpoint,
// 存储相关字段
storage_permission: user.storage_permission || 'oss_only',
current_storage_type: user.current_storage_type || 'oss',
local_storage_quota: user.local_storage_quota || 1073741824,
local_storage_quota: user.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES,
local_storage_used: user.local_storage_used || 0,
oss_storage_quota: effectiveOssQuota,
storage_used: user.storage_used || 0,
download_traffic_quota: effectiveDownloadTrafficQuota,
download_traffic_used: cappedDownloadTrafficUsed,
download_traffic_quota_expires_at: user.download_traffic_quota_expires_at || null,
download_traffic_reset_cycle: user.download_traffic_reset_cycle || 'none',
download_traffic_last_reset_at: user.download_traffic_last_reset_at || null,
// 主题偏好
theme_preference: user.theme_preference || null
};
Object.defineProperty(req.user, 'oss_access_key_secret', {
value: user.oss_access_key_secret ? decryptSecret(user.oss_access_key_secret) : null,
enumerable: false,
configurable: false,
writable: false
});
req.authSessionId = sessionId || null;
req.authTokenJti = typeof decoded.jti === 'string' ? decoded.jti : null;
next();
} catch (error) {
@@ -235,14 +340,21 @@ function adminMiddleware(req, res, next) {
* );
*/
function requirePasswordConfirmation(req, res, next) {
const { password } = req.body;
const password = String(
req.body?.current_password ||
req.body?.admin_password ||
req.body?.password_confirmation ||
req.body?.password ||
''
);
// 检查是否提供了密码
if (!password) {
return res.status(400).json({
success: false,
message: '执行此操作需要验证密码',
require_password: true
require_password: true,
requirePasswordConfirmation: true
});
}
@@ -269,7 +381,6 @@ function requirePasswordConfirmation(req, res, next) {
if (!isPasswordValid) {
// 记录安全日志:密码验证失败
SystemLogDB = require('./database').SystemLogDB;
SystemLogDB.log({
level: SystemLogDB.LEVELS.WARN,
category: SystemLogDB.CATEGORIES.SECURITY,
@@ -304,6 +415,8 @@ module.exports = {
JWT_SECRET,
generateToken,
generateRefreshToken,
decodeAccessToken,
decodeRefreshToken,
refreshAccessToken,
authMiddleware,
adminMiddleware,
+2083 -46
View File
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -19,8 +19,20 @@ shares.forEach(share => {
// 如果是ISO格式(包含T和Z),需要转换
if (oldFormat.includes('T') || oldFormat.includes('Z')) {
// 转换为 SQLite datetime 格式: YYYY-MM-DD HH:MM:SS
const newFormat = oldFormat.replace('T', ' ').replace(/\.\d+Z$/, '');
const parsed = new Date(oldFormat);
if (Number.isNaN(parsed.getTime())) {
console.warn(`跳过无法解析的时间: ${share.share_code} -> ${oldFormat}`);
return;
}
// 转换为本地时区 SQLite datetime 格式: YYYY-MM-DD HH:MM:SS
const year = parsed.getFullYear();
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
const hours = String(parsed.getHours()).padStart(2, '0');
const minutes = String(parsed.getMinutes()).padStart(2, '0');
const seconds = String(parsed.getSeconds()).padStart(2, '0');
const newFormat = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
updateStmt.run(newFormat, share.id);
fixed++;
+68
View File
@@ -0,0 +1,68 @@
const crypto = require('crypto');
const { logger } = require('../utils/logger');
const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_COOKIE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
function generateCsrfToken() {
return crypto.randomBytes(32).toString('hex');
}
function setCsrfCookie(res, token, secureCookies) {
res.cookie(CSRF_COOKIE_NAME, token, {
httpOnly: false,
secure: secureCookies,
sameSite: secureCookies ? 'strict' : 'lax',
maxAge: CSRF_COOKIE_MAX_AGE_MS
});
}
function createCsrfCookieMiddleware({ secureCookies = false } = {}) {
return (req, res, next) => {
if (!req.cookies[CSRF_COOKIE_NAME]) {
setCsrfCookie(res, generateCsrfToken(), secureCookies);
}
next();
};
}
function tokensEqual(left, right) {
if (typeof left !== 'string' || typeof right !== 'string') return false;
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function csrfProtection(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const hasCookieAuth = Boolean(req.cookies?.token || req.cookies?.refreshToken);
if (!hasCookieAuth) return next();
const cookieToken = req.cookies[CSRF_COOKIE_NAME];
const headerToken = req.headers['x-csrf-token'];
if (!tokensEqual(cookieToken, headerToken)) {
(req.log || logger).warn({
path: req.path,
hasCookieToken: Boolean(cookieToken),
hasHeaderToken: Boolean(headerToken)
}, 'CSRF validation failed');
return res.status(403).json({
success: false,
message: 'CSRF 验证失败,请刷新页面后重试',
requestId: req.id
});
}
return next();
}
module.exports = {
CSRF_COOKIE_NAME,
createCsrfCookieMiddleware,
csrfProtection,
generateCsrfToken,
setCsrfCookie,
tokensEqual
};
+26
View File
@@ -0,0 +1,26 @@
const { getRequestId, logger } = require('../utils/logger');
function expressErrorHandler(err, req, res, next) {
if (res.headersSent) return next(err);
const requestedStatus = Number(err?.statusCode || err?.status);
const status = Number.isInteger(requestedStatus) && requestedStatus >= 400 && requestedStatus <= 599
? requestedStatus
: 500;
const requestId = getRequestId(req);
(req.log || logger).error({
err,
method: req.method,
path: req.path || req.originalUrl,
statusCode: status
}, 'unhandled request error');
return res.status(status).json({
success: false,
message: status >= 500 ? '服务器内部错误' : (err?.message || '请求处理失败'),
...(requestId ? { requestId } : {})
});
}
module.exports = { expressErrorHandler };
+149
View File
@@ -0,0 +1,149 @@
const { rateLimit } = require('express-rate-limit');
const { logger } = require('../utils/logger');
function parsePositiveInteger(value, fallback) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function createGlobalApiLimiter(options = {}) {
const windowMs = parsePositiveInteger(options.windowMs ?? process.env.API_RATE_LIMIT_WINDOW_MS, 60_000);
const limit = parsePositiveInteger(options.limit ?? process.env.API_RATE_LIMIT_MAX, 600);
return rateLimit({
windowMs,
limit,
standardHeaders: 'draft-8',
legacyHeaders: false,
skip: (req) => req.method === 'OPTIONS' || req.path === '/health',
handler: (req, res) => {
(req.log || logger).warn({ method: req.method, path: req.path }, 'global API rate limit exceeded');
res.status(429).json({
success: false,
message: '请求过于频繁,请稍后再试',
requestId: req.id
});
}
});
}
class RateLimiter {
constructor(options = {}) {
this.maxAttempts = options.maxAttempts || 5;
this.windowMs = options.windowMs || 15 * 60 * 1000;
this.blockDuration = options.blockDuration || 30 * 60 * 1000;
this.attempts = new Map();
this.blockedKeys = new Map();
this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000);
this.cleanupInterval.unref?.();
}
getClientKey(req) {
return req.ip || req.socket?.remoteAddress || 'unknown';
}
isBlocked(key) {
const blockInfo = this.blockedKeys.get(key);
if (!blockInfo) return false;
if (Date.now() > blockInfo.expiresAt) {
this.blockedKeys.delete(key);
this.attempts.delete(key);
return false;
}
return true;
}
recordFailure(key) {
const now = Date.now();
if (this.isBlocked(key)) {
const blockInfo = this.blockedKeys.get(key);
return {
blocked: true,
remainingAttempts: 0,
resetTime: blockInfo.expiresAt,
waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60_000),
needCaptcha: true
};
}
let attemptInfo = this.attempts.get(key);
if (!attemptInfo || now > attemptInfo.windowEnd) {
attemptInfo = { count: 0, windowEnd: now + this.windowMs, firstAttempt: now };
}
attemptInfo.count += 1;
this.attempts.set(key, attemptInfo);
if (attemptInfo.count >= this.maxAttempts) {
const blockExpiresAt = now + this.blockDuration;
this.blockedKeys.set(key, { expiresAt: blockExpiresAt, blockedAt: now });
logger.warn({ key, attempts: attemptInfo.count, blockDuration: this.blockDuration }, 'failure limiter blocked key');
return {
blocked: true,
remainingAttempts: 0,
resetTime: blockExpiresAt,
waitMinutes: Math.ceil(this.blockDuration / 60_000),
needCaptcha: true
};
}
return {
blocked: false,
remainingAttempts: this.maxAttempts - attemptInfo.count,
resetTime: attemptInfo.windowEnd,
waitMinutes: 0,
needCaptcha: attemptInfo.count >= 2
};
}
getFailureCount(key) {
const attemptInfo = this.attempts.get(key);
if (!attemptInfo || Date.now() > attemptInfo.windowEnd) return 0;
return attemptInfo.count;
}
recordSuccess(key) {
this.attempts.delete(key);
this.blockedKeys.delete(key);
}
cleanup() {
const now = Date.now();
let cleanedAttempts = 0;
let cleanedBlocks = 0;
for (const [key, info] of this.attempts.entries()) {
if (now > info.windowEnd) {
this.attempts.delete(key);
cleanedAttempts += 1;
}
}
for (const [key, info] of this.blockedKeys.entries()) {
if (now > info.expiresAt) {
this.blockedKeys.delete(key);
cleanedBlocks += 1;
}
}
if (cleanedAttempts || cleanedBlocks) {
logger.info({ cleanedAttempts, cleanedBlocks }, 'failure limiter cleanup completed');
}
}
getStats() {
return {
activeAttempts: this.attempts.size,
blockedKeys: this.blockedKeys.size
};
}
destroy() {
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
}
}
module.exports = {
RateLimiter,
createGlobalApiLimiter
};
+49
View File
@@ -0,0 +1,49 @@
const LOCAL_HEALTH_ADDRESSES = new Set([
'127.0.0.1',
'::1',
'::ffff:127.0.0.1'
]);
function applySecurityHeaders(req, res, options = {}) {
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), microphone=()');
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' blob: https:; connect-src 'self' https: ws: wss:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self';"
);
if (req?.secure || (!req && options.secureByDefault)) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
res.removeHeader('X-Powered-By');
}
function securityHeadersMiddleware(options = {}) {
return (req, res, next) => {
applySecurityHeaders(req, res, options);
next();
};
}
function createHttpsEnforcementMiddleware({ enabled = false } = {}) {
return (req, res, next) => {
const remoteAddress = req.socket?.remoteAddress || '';
const isLocalHealthCheck = req.path === '/api/health' && LOCAL_HEALTH_ADDRESSES.has(remoteAddress);
if (!enabled || isLocalHealthCheck || req.secure) return next();
return res.status(400).json({
success: false,
message: '仅支持HTTPS访问,请使用HTTPS',
requestId: req.id
});
};
}
module.exports = {
applySecurityHeaders,
createHttpsEnforcementMiddleware,
securityHeadersMiddleware
};
+642 -1248
View File
File diff suppressed because it is too large Load Diff
+16 -10
View File
@@ -3,9 +3,15 @@
"version": "3.1.0",
"description": "玩玩云 - 云存储管理平台后端服务",
"main": "server.js",
"engines": {
"node": ">=20.19 <25"
},
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"dev": "node --watch server.js",
"test": "npm run test:unit && npm run test:integration && node test_download_quota_defaults.js",
"test:unit": "node tests/run-all-tests.js",
"test:integration": "node tests/full-audit-regression.js"
},
"keywords": [
"cloud-storage",
@@ -18,23 +24,23 @@
"author": "玩玩云团队",
"license": "MIT",
"dependencies": {
"archiver": "^7.0.1",
"@aws-sdk/client-s3": "^3.985.0",
"@aws-sdk/s3-request-presigner": "^3.985.0",
"archiver": "^8.0.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-session": "^1.18.2",
"express-async-errors": "3.1.1",
"express-rate-limit": "8.6.1",
"express-validator": "^7.3.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"nodemailer": "^6.9.14",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
"lodash": "^4.17.23",
"multer": "^2.2.0",
"nodemailer": "^9.0.3",
"pino": "10.3.1",
"svg-captcha": "^1.4.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
-52
View File
@@ -1,52 +0,0 @@
/**
* 健康检查和公共配置路由
* 提供服务健康状态和公共配置信息
*/
const express = require('express');
const router = express.Router();
const { SettingsDB } = require('../database');
/**
* 健康检查端点
* GET /api/health
*/
router.get('/health', (req, res) => {
res.json({ success: true, message: 'Server is running' });
});
/**
* 获取公开的系统配置(不需要登录)
* GET /api/config
*/
router.get('/config', (req, res) => {
const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240');
res.json({
success: true,
config: {
max_upload_size: maxUploadSize
}
});
});
/**
* 获取公开的全局主题设置(不需要登录)
* GET /api/public/theme
*/
router.get('/public/theme', (req, res) => {
try {
const globalTheme = SettingsDB.get('global_theme') || 'dark';
res.json({
success: true,
theme: globalTheme
});
} catch (error) {
console.error('获取全局主题失败:', error);
res.status(500).json({
success: false,
message: '获取主题失败'
});
}
});
module.exports = router;
-90
View File
@@ -1,90 +0,0 @@
/**
* 路由模块索引
*
* 本项目的路由目前主要定义在 server.js 中。
* 此目录用于未来路由拆分的模块化重构。
*
* 建议的路由模块拆分方案:
*
* 1. routes/health.js - 健康检查和公共配置
* - GET /api/health
* - GET /api/config
* - GET /api/public/theme
*
* 2. routes/auth.js - 认证相关
* - POST /api/login
* - POST /api/register
* - POST /api/logout
* - POST /api/refresh-token
* - POST /api/password/forgot
* - POST /api/password/reset
* - GET /api/verify-email
* - POST /api/resend-verification
* - GET /api/captcha
* - GET /api/csrf-token
*
* 3. routes/user.js - 用户相关
* - GET /api/user/profile
* - GET /api/user/theme
* - POST /api/user/theme
* - POST /api/user/update-oss
* - POST /api/user/test-oss
* - GET /api/user/oss-usage
* - POST /api/user/change-password
* - POST /api/user/update-username
* - POST /api/user/switch-storage
*
* 4. routes/files.js - 文件操作
* - GET /api/files
* - POST /api/files/rename
* - POST /api/files/mkdir
* - POST /api/files/folder-info
* - POST /api/files/delete
* - GET /api/files/upload-signature
* - POST /api/files/upload-complete
* - GET /api/files/download-url
* - GET /api/files/download
* - POST /api/upload
*
* 5. routes/share.js - 分享功能
* - POST /api/share/create
* - GET /api/share/my
* - DELETE /api/share/:id
* - GET /api/share/:code/theme
* - POST /api/share/:code/verify
* - POST /api/share/:code/list
* - POST /api/share/:code/download
* - GET /api/share/:code/download-url
* - GET /api/share/:code/download-file
*
* 6. routes/admin.js - 管理员功能
* - GET /api/admin/settings
* - POST /api/admin/settings
* - POST /api/admin/settings/test-smtp
* - GET /api/admin/health-check
* - GET /api/admin/storage-stats
* - GET /api/admin/users
* - GET /api/admin/logs
* - GET /api/admin/logs/stats
* - POST /api/admin/logs/cleanup
* - POST /api/admin/users/:id/ban
* - DELETE /api/admin/users/:id
* - POST /api/admin/users/:id/storage-permission
* - GET /api/admin/users/:id/files
* - GET /api/admin/shares
* - DELETE /api/admin/shares/:id
* - GET /api/admin/check-upload-tool
* - POST /api/admin/upload-tool
*
* 使用示例(在 server.js 中):
* ```javascript
* const healthRoutes = require('./routes/health');
* app.use('/api', healthRoutes);
* ```
*/
const healthRoutes = require('./health');
module.exports = {
healthRoutes
};
+35
View File
@@ -0,0 +1,35 @@
const express = require('express');
function createSystemRouter({ SettingsDB, healthService }) {
const router = express.Router();
router.get('/health', (req, res) => {
const result = healthService.check();
res.status(result.healthy ? 200 : 503).json({
success: result.healthy,
status: result.status,
uptimeSeconds: Math.floor(process.uptime()),
timestamp: new Date().toISOString(),
requestId: req.id,
checks: result.checks
});
});
router.get('/config', (req, res) => {
const maxUploadSize = parseInt(SettingsDB.get('max_upload_size') || '10737418240', 10);
res.json({ success: true, config: { max_upload_size: maxUploadSize } });
});
router.get('/public/theme', (req, res) => {
try {
const globalTheme = SettingsDB.get('global_theme') || 'dark';
res.json({ success: true, theme: globalTheme });
} catch {
res.json({ success: true, theme: 'dark' });
}
});
return router;
}
module.exports = { createSystemRouter };
+5796 -1039
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
const fs = require('fs');
const DEFAULT_MIN_FREE_BYTES = 256 * 1024 * 1024;
function normalizeMinimumFreeBytes(value) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_MIN_FREE_BYTES;
}
function createHealthService({ db, diskPath, minimumFreeBytes = process.env.HEALTH_MIN_DISK_FREE_BYTES }) {
const diskThreshold = normalizeMinimumFreeBytes(minimumFreeBytes);
function checkDatabase() {
const startedAt = process.hrtime.bigint();
try {
const row = db.prepare('SELECT 1 AS ok').get();
return {
status: row?.ok === 1 ? 'ok' : 'error',
latencyMs: Number((Number(process.hrtime.bigint() - startedAt) / 1e6).toFixed(2))
};
} catch (error) {
return { status: 'error', message: error.code || error.name || 'database_error' };
}
}
function checkDisk() {
try {
const stats = fs.statfsSync(diskPath);
const freeBytes = Number(BigInt(stats.bavail) * BigInt(stats.bsize));
return {
status: freeBytes >= diskThreshold ? 'ok' : 'low',
freeBytes,
minimumFreeBytes: diskThreshold
};
} catch (error) {
return { status: 'error', message: error.code || error.name || 'disk_error' };
}
}
function check() {
const checks = {
database: checkDatabase(),
disk: checkDisk()
};
const healthy = checks.database.status === 'ok' && checks.disk.status === 'ok';
return {
healthy,
status: healthy ? 'ok' : 'degraded',
checks
};
}
return { check };
}
module.exports = {
createHealthService,
normalizeMinimumFreeBytes
};
+328 -257
View File
@@ -1,4 +1,16 @@
const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand, CopyObjectCommand } = require('@aws-sdk/client-s3');
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectsCommand,
ListObjectsV2Command,
HeadObjectCommand,
CopyObjectCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand
} = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const fs = require('fs');
const path = require('path');
@@ -18,6 +30,26 @@ function formatFileSize(bytes) {
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function parsePositiveInteger(value, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return Math.floor(parsed);
}
const OSS_MULTIPART_MAX_PARTS = 10000;
const OSS_MULTIPART_MIN_PART_SIZE = 5 * 1024 * 1024; // S3 协议要求最小 5MB(最后一片除外)
const OSS_MULTIPART_DEFAULT_PART_SIZE = Math.max(
OSS_MULTIPART_MIN_PART_SIZE,
parsePositiveInteger(process.env.OSS_MULTIPART_PART_SIZE_BYTES, 16 * 1024 * 1024)
);
const OSS_SINGLE_UPLOAD_THRESHOLD = Math.max(
OSS_MULTIPART_MIN_PART_SIZE,
parsePositiveInteger(process.env.OSS_SINGLE_UPLOAD_THRESHOLD_BYTES, 64 * 1024 * 1024)
);
const OSS_RENAME_RECOVERY_KEYS = new Set();
/**
* 将 OSS/网络错误转换为友好的错误信息
* @param {Error} error - 原始错误
@@ -199,8 +231,10 @@ class LocalStorageClient {
// 检查配额:只检查净增量(新文件大小 - 旧文件大小)
const netIncrease = newFileSize - oldFileSize;
let reservedDelta = 0;
if (netIncrease > 0) {
this.checkQuota(netIncrease);
reservedDelta = netIncrease;
}
// 确保目标目录存在
@@ -211,27 +245,54 @@ class LocalStorageClient {
// 使用临时文件+重命名模式,避免文件被占用问题
const tempPath = `${destPath}.uploading_${Date.now()}`;
const backupPath = `${destPath}.backup_${Date.now()}`;
let backupCreated = false;
let destReplaced = false;
try {
// 复制到临时文件
fs.copyFileSync(localPath, tempPath);
// 如果目标文件存在,先删除
if (fs.existsSync(destPath)) {
fs.unlinkSync(destPath);
fs.renameSync(destPath, backupPath);
backupCreated = true;
}
// 重命名临时文件为目标文件
fs.renameSync(tempPath, destPath);
// 优先尝试 rename(同文件系统下瞬时完成,大文件不再需要逐字节复制)
try {
fs.renameSync(localPath, destPath);
destReplaced = true;
} catch (renameErr) {
if (renameErr.code === 'EXDEV') {
// 跨文件系统,回退到 copy + rename
fs.copyFileSync(localPath, tempPath);
fs.renameSync(tempPath, destPath);
destReplaced = true;
} else {
throw renameErr;
}
}
// 更新已使用空间(使用净增量)
if (netIncrease !== 0) {
if (backupCreated) {
try { fs.unlinkSync(backupPath); } catch (_) {}
}
// 正向净增已在写入前原子预留,这里只处理覆盖变小时的扣减。
if (netIncrease < 0) {
this.updateUsedSpace(netIncrease);
}
} catch (error) {
// 清理临时文件
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
try { fs.unlinkSync(tempPath); } catch (_) {}
}
if (destReplaced && fs.existsSync(destPath)) {
try { fs.unlinkSync(destPath); } catch (_) {}
}
if (backupCreated && fs.existsSync(backupPath) && !fs.existsSync(destPath)) {
try { fs.renameSync(backupPath, destPath); } catch (restoreError) {
console.error(`[本地存储] 恢复旧文件失败: ${restoreError.message}`);
}
}
if (reservedDelta > 0) {
this.updateUsedSpace(-reservedDelta);
}
throw error;
}
@@ -372,14 +433,14 @@ class LocalStorageClient {
* @param {string} filePath - 文件路径
* @returns {ReadStream} 文件读取流
*/
createReadStream(filePath) {
createReadStream(filePath, options = undefined) {
const fullPath = this.getFullPath(filePath);
if (!fs.existsSync(fullPath)) {
throw new Error(`文件不存在: ${filePath}`);
}
return fs.createReadStream(fullPath);
return fs.createReadStream(fullPath, options);
}
/**
@@ -467,12 +528,13 @@ class LocalStorageClient {
// 5. 拼接完整路径
const fullPath = path.join(this.basePath, normalized);
// 6. 解析真实路径(处理符号链接)后再次验证
// 6. 解析绝对路径后再次验证
const resolvedBasePath = path.resolve(this.basePath);
const resolvedFullPath = path.resolve(fullPath);
const relativeToBase = path.relative(resolvedBasePath, resolvedFullPath);
// 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击)
if (!resolvedFullPath.startsWith(resolvedBasePath)) {
if (relativeToBase.startsWith('..') || path.isAbsolute(relativeToBase)) {
console.warn('[安全] 检测到路径遍历攻击:', {
input: relativePath,
resolved: resolvedFullPath,
@@ -488,225 +550,26 @@ class LocalStorageClient {
* 检查配额
*/
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 amount = Math.max(0, Math.trunc(Number(additionalSize) || 0));
if (amount === 0) return;
const updatedUser = UserDB.reserveLocalStorageSpace(this.user.id, amount);
if (!updatedUser) {
const latestUser = UserDB.findById(this.user.id) || this.user;
const used = this.formatSize(latestUser.local_storage_used || 0);
const quota = this.formatSize(this.user.local_storage_quota);
const need = this.formatSize(additionalSize);
const need = this.formatSize(amount);
throw new Error(`存储配额不足。已使用: ${used}, 配额: ${quota}, 需要: ${need}`);
}
this.user.local_storage_used = Number(updatedUser.local_storage_used || 0);
}
/**
* 更新已使用空间
*/
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;
}
/**
* 恢复未完成的重命名操作(启动时调用)
* 扫描OSS存储中的待处理重命名标记文件,执行回滚或完成操作
*
* **重命名操作的两个阶段:**
* 1. copying 阶段:正在复制文件到新位置
* - 恢复策略:删除已复制的目标文件,保留原文件
* 2. deleting 阶段:正在删除原文件
* - 恢复策略:确保原文件被完全删除(补充删除逻辑)
*
* @private
*/
async recoverPendingRenames() {
try {
console.log('[OSS存储] 检查未完成的重命名操作...');
const bucket = this.getBucket();
const markerPrefix = this.prefix + '.rename_pending_';
// 列出所有待处理的标记文件
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: markerPrefix,
MaxKeys: 100
});
const response = await this.s3Client.send(listCommand);
if (!response.Contents || response.Contents.length === 0) {
console.log('[OSS存储] 没有未完成的重命名操作');
return;
}
console.log(`[OSS存储] 发现 ${response.Contents.length} 个未完成的重命名操作,开始恢复...`);
for (const marker of response.Contents) {
try {
// 从标记文件名中解析元数据
// 格式: .rename_pending_{timestamp}_{oldKeyHash}.json
const markerKey = marker.Key;
// 读取标记文件内容
const getMarkerCommand = new GetObjectCommand({
Bucket: bucket,
Key: markerKey
});
const markerResponse = await this.s3Client.send(getMarkerCommand);
const markerContent = await streamToBuffer(markerResponse.Body);
const metadata = JSON.parse(markerContent.toString());
const { oldPrefix, newPrefix, timestamp, phase } = metadata;
// 检查标记是否过期(超过1小时视为失败,需要恢复)
const age = Date.now() - timestamp;
const TIMEOUT = 60 * 60 * 1000; // 1小时
if (age > TIMEOUT) {
console.warn(`[OSS存储] 检测到超时的重命名操作: ${oldPrefix} -> ${newPrefix}, 阶段: ${phase}`);
// 根据不同阶段执行不同的恢复策略
if (phase === 'copying') {
// ===== 第一阶段:复制阶段超时 =====
// 策略:删除已复制的目标文件,保留原文件
console.log(`[OSS存储] [copying阶段] 执行回滚: 删除已复制的文件 ${newPrefix}`);
await this._rollbackRename(oldPrefix, newPrefix);
} else if (phase === 'deleting') {
// ===== 第二阶段:删除阶段超时(第二轮修复) =====
// 策略:补充完整的删除逻辑,确保原文件被清理干净
console.log(`[OSS存储] [deleting阶段] 执行补充删除: 清理剩余原文件 ${oldPrefix}`);
try {
// 步骤1:列出原位置的所有剩余文件
let continuationToken = null;
let remainingCount = 0;
const MAX_KEYS_PER_REQUEST = 1000;
do {
const listOldCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: oldPrefix,
MaxKeys: MAX_KEYS_PER_REQUEST,
ContinuationToken: continuationToken
});
const listOldResponse = await this.s3Client.send(listOldCommand);
continuationToken = listOldResponse.NextContinuationToken;
if (listOldResponse.Contents && listOldResponse.Contents.length > 0) {
// 步骤2:批量删除剩余的原文件
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: listOldResponse.Contents.map(obj => ({ Key: obj.Key })),
Quiet: true
}
});
const deleteResult = await this.s3Client.send(deleteCommand);
remainingCount += listOldResponse.Contents.length;
console.log(`[OSS存储] [deleting阶段] 已删除 ${listOldResponse.Contents.length} 个剩余原文件`);
// 检查删除结果
if (deleteResult.Errors && deleteResult.Errors.length > 0) {
console.warn(`[OSS存储] [deleting阶段] 部分文件删除失败:`, deleteResult.Errors);
}
}
} while (continuationToken);
if (remainingCount > 0) {
console.log(`[OSS存储] [deleting阶段] 补充删除完成: 清理了 ${remainingCount} 个原文件`);
} else {
console.log(`[OSS存储] [deleting阶段] 原位置 ${oldPrefix} 已是空的,无需清理`);
}
} catch (cleanupError) {
console.error(`[OSS存储] [deleting阶段] 补充删除失败: ${cleanupError.message}`);
// 继续执行,不中断流程
}
} else {
// 未知阶段,记录警告
console.warn(`[OSS存储] 未知阶段 ${phase},跳过恢复`);
}
// 删除标记文件(完成恢复后清理)
await this.s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: [{ Key: markerKey }],
Quiet: true
}
}));
console.log(`[OSS存储] 已清理超时的重命名标记: ${markerKey}`);
} else {
console.log(`[OSS存储] 重命名操作仍在进行中: ${oldPrefix} -> ${newPrefix} (阶段: ${phase}, 剩余: ${Math.floor((TIMEOUT - age) / 1000)}秒)`);
}
} catch (error) {
console.error(`[OSS存储] 恢复重命名操作失败: ${marker.Key}`, error.message);
// 继续处理下一个标记文件
}
}
console.log('[OSS存储] 重命名操作恢复完成');
} catch (error) {
console.error('[OSS存储] 恢复重命名操作时出错:', error.message);
}
}
/**
* 回滚重命名操作(删除已复制的目标文件)
* @param {string} oldPrefix - 原前缀
* @param {string} newPrefix - 新前缀
* @private
*/
async _rollbackRename(oldPrefix, newPrefix) {
const bucket = this.getBucket();
const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`;
try {
// 列出所有已复制的对象
let continuationToken = null;
let deletedCount = 0;
do {
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: newPrefixWithSlash,
ContinuationToken: continuationToken,
MaxKeys: 1000
});
const listResponse = await this.s3Client.send(listCommand);
continuationToken = listResponse.NextContinuationToken;
if (listResponse.Contents && listResponse.Contents.length > 0) {
// 批量删除
const deleteCommand = new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })),
Quiet: true
}
});
await this.s3Client.send(deleteCommand);
deletedCount += listResponse.Contents.length;
}
} while (continuationToken);
if (deletedCount > 0) {
console.log(`[OSS存储] 回滚完成: 删除了 ${deletedCount} 个对象`);
}
} catch (error) {
console.error(`[OSS存储] 回滚失败: ${error.message}`);
throw error;
}
const updatedUser = UserDB.adjustLocalStorageUsed(this.user.id, delta);
this.user.local_storage_used = Number(updatedUser?.local_storage_used || 0);
}
/**
@@ -888,6 +751,16 @@ class OssStorageClient {
this.s3Client = new S3Client(s3Config);
console.log(`[OSS存储] 已连接: ${ossConfig.oss_provider}, bucket: ${ossConfig.oss_bucket}, 使用统一配置: ${this.useUnifiedConfig}`);
const recoveryKey = `${ossConfig.oss_bucket}:${this.prefix}`;
if (!OSS_RENAME_RECOVERY_KEYS.has(recoveryKey)) {
OSS_RENAME_RECOVERY_KEYS.add(recoveryKey);
this.recoverPendingRenames().catch(error => {
OSS_RENAME_RECOVERY_KEYS.delete(recoveryKey);
console.error('[OSS存储] 重命名恢复任务失败:', error.message);
});
}
return this;
} catch (error) {
console.error(`[OSS存储] 连接失败:`, error.message);
@@ -895,6 +768,113 @@ class OssStorageClient {
}
}
/**
* 恢复未完成的重命名操作。
* 扫描 OSS 中的 .rename_pending_* 标记,回滚 copying 阶段或补删 deleting 阶段。
*/
async recoverPendingRenames() {
try {
console.log('[OSS存储] 检查未完成的重命名操作...');
const bucket = this.getBucket();
const markerPrefix = this.prefix + '.rename_pending_';
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: markerPrefix,
MaxKeys: 100
});
const response = await this.s3Client.send(listCommand);
if (!response.Contents || response.Contents.length === 0) {
console.log('[OSS存储] 没有未完成的重命名操作');
return;
}
console.log(`[OSS存储] 发现 ${response.Contents.length} 个未完成的重命名操作,开始恢复...`);
for (const marker of response.Contents) {
try {
const markerKey = marker.Key;
const markerResponse = await this.s3Client.send(new GetObjectCommand({
Bucket: bucket,
Key: markerKey
}));
const markerContent = await streamToBuffer(markerResponse.Body);
const metadata = JSON.parse(markerContent.toString());
const { oldPrefix, newPrefix, timestamp, phase } = metadata;
const age = Date.now() - Number(timestamp || 0);
const timeoutMs = 60 * 60 * 1000;
if (age <= timeoutMs) {
console.log(`[OSS存储] 重命名操作仍在进行中: ${oldPrefix} -> ${newPrefix} (阶段: ${phase})`);
continue;
}
console.warn(`[OSS存储] 检测到超时的重命名操作: ${oldPrefix} -> ${newPrefix}, 阶段: ${phase}`);
if (phase === 'copying') {
await this._rollbackRename(oldPrefix, newPrefix);
} else if (phase === 'deleting') {
await this._deletePrefixObjects(oldPrefix);
} else {
console.warn(`[OSS存储] 未知阶段 ${phase},仅清理标记文件`);
}
await this.s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: [{ Key: markerKey }],
Quiet: true
}
}));
console.log(`[OSS存储] 已清理超时的重命名标记: ${markerKey}`);
} catch (error) {
console.error(`[OSS存储] 恢复重命名操作失败: ${marker.Key}`, error.message);
}
}
console.log('[OSS存储] 重命名操作恢复完成');
} catch (error) {
console.error('[OSS存储] 恢复重命名操作时出错:', error.message);
}
}
async _rollbackRename(oldPrefix, newPrefix) {
const newPrefixWithSlash = newPrefix.endsWith('/') ? newPrefix : `${newPrefix}/`;
await this._deletePrefixObjects(newPrefixWithSlash);
}
async _deletePrefixObjects(prefix) {
const bucket = this.getBucket();
const safePrefix = prefix.endsWith('/') ? prefix : `${prefix}/`;
let continuationToken = null;
let deletedCount = 0;
do {
const listResponse = await this.s3Client.send(new ListObjectsV2Command({
Bucket: bucket,
Prefix: safePrefix,
ContinuationToken: continuationToken,
MaxKeys: 1000
}));
continuationToken = listResponse.NextContinuationToken;
if (listResponse.Contents && listResponse.Contents.length > 0) {
await this.s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })),
Quiet: true
}
}));
deletedCount += listResponse.Contents.length;
}
} while (continuationToken);
if (deletedCount > 0) {
console.log(`[OSS存储] 已删除前缀 ${safePrefix}${deletedCount} 个对象`);
}
}
/**
* 获取当前使用的 bucket 名称
* @returns {string}
@@ -939,22 +919,27 @@ class OssStorageClient {
throw new Error('路径包含非法字符');
}
// 2. 先进行 URL 解码(防止双重编码绕过)
let decoded = relativePath;
// 2. 仅将 URL 解码结果用于安全检查,不用于生成对象 key。
// 文件名中的字面量 "%2F" 应保留为普通字符,不能变成路径分隔符。
let decodedForSecurity = relativePath;
try {
decoded = decodeURIComponent(relativePath);
decodedForSecurity = decodeURIComponent(relativePath);
} catch (e) {
// 解码失败使用原始值
// 解码失败使用原始值做后续检查
}
// 3. 检查解码后的空字节
if (decoded.includes('\x00')) {
if (decodedForSecurity.includes('\x00')) {
console.warn('[OSS安全] 检测到编码的空字节注入:', relativePath);
throw new Error('路径包含非法字符');
}
if (decodedForSecurity.includes('..')) {
console.warn('[OSS安全] 检测到编码的目录遍历尝试:', relativePath);
throw new Error('路径包含非法字符');
}
// 4. 规范化路径:统一使用正斜杠(OSS 使用正斜杠作为分隔符)
let normalized = decoded
let normalized = relativePath
.replace(/\\/g, '/') // 将反斜杠转换为正斜杠
.replace(/\/+/g, '/'); // 合并多个连续斜杠
@@ -1090,9 +1075,13 @@ class OssStorageClient {
* @param {string} remotePath - 远程文件路径
*/
async put(localPath, remotePath) {
let bucket = '';
let key = '';
let uploadId = null;
let fileHandle = null;
try {
const key = this.getObjectKey(remotePath);
const bucket = this.getBucket();
key = this.getObjectKey(remotePath);
bucket = this.getBucket();
// 检查本地文件是否存在
if (!fs.existsSync(localPath)) {
@@ -1102,30 +1091,107 @@ class OssStorageClient {
const fileStats = fs.statSync(localPath);
const fileSize = fileStats.size;
// 检查文件大小(AWS S3 单次上传最大 5GB)
const MAX_SINGLE_UPLOAD_SIZE = 5 * 1024 * 1024 * 1024; // 5GB
if (fileSize > MAX_SINGLE_UPLOAD_SIZE) {
throw new Error(`文件过大 (${formatFileSize(fileSize)}),单次上传最大支持 5GB,请使用分片上传`);
if (fileSize <= OSS_SINGLE_UPLOAD_THRESHOLD) {
// 小文件保持单请求上传,兼容性更高。
const fileContent = fs.readFileSync(localPath);
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: fileContent,
ContentLength: fileSize,
ChecksumAlgorithm: undefined
});
await this.s3Client.send(command);
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
return;
}
// 使用Buffer上传而非流式上传,避免AWS SDK使用aws-chunked编码
// 这样可以确保与阿里云OSS的兼容性
const fileContent = fs.readFileSync(localPath);
// 大文件自动切换到分片上传,避免整文件读入内存和 5GB 单请求上限。
const minPartSizeByCount = Math.ceil(fileSize / OSS_MULTIPART_MAX_PARTS);
const alignedMinPartSize = Math.ceil(
Math.max(minPartSizeByCount, OSS_MULTIPART_MIN_PART_SIZE) / (1024 * 1024)
) * 1024 * 1024;
const partSize = Math.max(OSS_MULTIPART_DEFAULT_PART_SIZE, alignedMinPartSize);
const estimatedParts = Math.ceil(fileSize / partSize);
if (estimatedParts > OSS_MULTIPART_MAX_PARTS) {
throw new Error(`文件过大,分片数量超限 (${estimatedParts}/${OSS_MULTIPART_MAX_PARTS})`);
}
// 直接上传
const command = new PutObjectCommand({
const createResp = await this.s3Client.send(new CreateMultipartUploadCommand({
Bucket: bucket,
Key: key
}));
uploadId = createResp?.UploadId || null;
if (!uploadId) {
throw new Error('创建分片上传会话失败:缺少 UploadId');
}
const uploadedParts = [];
let offset = 0;
let partNumber = 1;
fileHandle = await fs.promises.open(localPath, 'r');
while (offset < fileSize) {
const remaining = fileSize - offset;
const currentPartSize = Math.min(partSize, remaining);
const buffer = Buffer.allocUnsafe(currentPartSize);
const { bytesRead } = await fileHandle.read(buffer, 0, currentPartSize, offset);
if (bytesRead <= 0) {
throw new Error('读取本地文件失败,分片上传中断');
}
const body = bytesRead === currentPartSize ? buffer : buffer.subarray(0, bytesRead);
const partResp = await this.s3Client.send(new UploadPartCommand({
Bucket: bucket,
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
Body: body,
ContentLength: bytesRead
}));
if (!partResp?.ETag) {
throw new Error(`分片 ${partNumber} 上传失败:服务端未返回 ETag`);
}
uploadedParts.push({
ETag: partResp.ETag,
PartNumber: partNumber
});
offset += bytesRead;
if (partNumber === 1 || partNumber % 10 === 0 || offset >= fileSize) {
console.log(
`[OSS存储] 上传分片进度: ${key} ${partNumber}/${estimatedParts} (${formatFileSize(offset)}/${formatFileSize(fileSize)})`
);
}
partNumber += 1;
}
await this.s3Client.send(new CompleteMultipartUploadCommand({
Bucket: bucket,
Key: key,
Body: fileContent,
ContentLength: fileSize, // 明确指定内容长度,避免某些服务端问题
// 禁用checksum算法(阿里云OSS不完全支持AWS的x-amz-content-sha256头)
ChecksumAlgorithm: undefined
});
await this.s3Client.send(command);
console.log(`[OSS存储] 上传成功: ${key} (${formatFileSize(fileSize)})`);
UploadId: uploadId,
MultipartUpload: { Parts: uploadedParts }
}));
uploadId = null;
console.log(
`[OSS存储] 分片上传完成: ${key} (${formatFileSize(fileSize)}, ${uploadedParts.length} 片)`
);
} catch (error) {
if (uploadId && bucket && key) {
try {
await this.s3Client.send(new AbortMultipartUploadCommand({
Bucket: bucket,
Key: key,
UploadId: uploadId
}));
console.warn(`[OSS存储] 已中止失败的分片上传: ${key}`);
} catch (abortError) {
console.error(`[OSS存储] 中止分片上传失败: ${key}`, abortError.message);
}
}
console.error(`[OSS存储] 上传失败: ${remotePath}`, error.message);
// 判断错误类型并给出友好的错误信息
@@ -1139,6 +1205,10 @@ class OssStorageClient {
throw new Error(`本地文件不存在: ${localPath}`);
}
throw new Error(`文件上传失败: ${error.message}`);
} finally {
if (fileHandle) {
await fileHandle.close().catch(() => {});
}
}
}
@@ -1157,7 +1227,7 @@ class OssStorageClient {
try {
statResult = await this.stat(filePath);
} catch (statError) {
if (statError.message && statResult?.message.includes('不存在')) {
if (statError.message && statError.message.includes('不存在')) {
console.warn(`[OSS存储] 文件不存在,跳过删除: ${key}`);
return { size: 0 }; // 文件不存在,返回大小为 0
}
@@ -1167,6 +1237,7 @@ class OssStorageClient {
let totalDeletedSize = 0;
if (statResult.isDirectory) {
const directoryPrefix = key.endsWith('/') ? key : `${key}/`;
// 删除目录:列出所有对象并批量删除
// 使用分页循环处理超过 1000 个对象的情况
let continuationToken = null;
@@ -1176,7 +1247,7 @@ class OssStorageClient {
do {
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: key,
Prefix: directoryPrefix,
MaxKeys: MAX_DELETE_BATCH,
ContinuationToken: continuationToken
});
@@ -1211,7 +1282,7 @@ class OssStorageClient {
} while (continuationToken);
if (totalDeletedCount > 0) {
console.log(`[OSS存储] 删除目录: ${key} (${totalDeletedCount} 个对象, ${totalDeletedSize} 字节)`);
console.log(`[OSS存储] 删除目录: ${directoryPrefix} (${totalDeletedCount} 个对象, ${totalDeletedSize} 字节)`);
}
return { size: totalDeletedSize };
@@ -1534,7 +1605,7 @@ class OssStorageClient {
*/
async createReadStream(filePath) {
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
const bucket = this.getBucket();
const command = new GetObjectCommand({
Bucket: bucket,
@@ -1589,7 +1660,7 @@ class OssStorageClient {
*/
async getPresignedUrl(filePath, expiresIn = 3600) {
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
const bucket = this.getBucket();
try {
const command = new GetObjectCommand({
@@ -1617,7 +1688,7 @@ class OssStorageClient {
const key = this.getObjectKey(filePath);
const bucket = this.getBucket();
const provider = this.getProvider();
const region = this.s3Client.config.region;
const region = String(this.currentConfig?.oss_region || this.user.oss_region || 'us-east-1');
let baseUrl;
if (provider === 'aliyun') {
@@ -1646,7 +1717,7 @@ class OssStorageClient {
*/
async getUploadPresignedUrl(filePath, expiresIn = 900, contentType = 'application/octet-stream') {
const key = this.getObjectKey(filePath);
const bucket = this.user.oss_bucket;
const bucket = this.getBucket();
try {
const command = new PutObjectCommand({
+1
View File
@@ -0,0 +1 @@
This is a test file for upload testing
+586
View File
@@ -0,0 +1,586 @@
/**
* 管理员功能完整性测试脚本(Cookie + CSRF 认证模型)
*
* 覆盖范围:
* 1. 鉴权与权限校验
* 2. 用户管理(列表、封禁/删除自保护、存储权限)
* 3. 系统设置(获取/更新/参数校验)
* 4. 分享管理
* 5. 系统监控(健康、存储、日志)
* 6. 上传工具接口
*/
const http = require('http');
const https = require('https');
const { UserDB } = require('./database');
const BASE_URL = process.env.TEST_BASE_URL || 'http://127.0.0.1:40001';
const state = {
adminSession: {
cookies: {},
csrfToken: ''
},
adminUserId: null,
testUserId: null,
latestSettings: null
};
const testResults = {
passed: [],
failed: [],
warnings: []
};
function makeCookieHeader(cookies) {
return Object.entries(cookies || {})
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
function storeSetCookies(session, setCookieHeader) {
if (!session || !setCookieHeader) return;
const list = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
for (const raw of list) {
const first = String(raw || '').split(';')[0];
const idx = first.indexOf('=');
if (idx <= 0) continue;
const key = first.slice(0, idx).trim();
const value = first.slice(idx + 1).trim();
session.cookies[key] = value;
if (key === 'csrf_token') {
session.csrfToken = value;
}
}
}
function isSafeMethod(method) {
const upper = String(method || '').toUpperCase();
return upper === 'GET' || upper === 'HEAD' || upper === 'OPTIONS';
}
function request(method, path, options = {}) {
const {
data = null,
session = null,
headers = {},
requireCsrf = true
} = options;
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const transport = url.protocol === 'https:' ? https : http;
const requestHeaders = { ...headers };
if (session) {
const cookieHeader = makeCookieHeader(session.cookies);
if (cookieHeader) {
requestHeaders.Cookie = cookieHeader;
}
if (requireCsrf && !isSafeMethod(method)) {
const csrfToken = session.csrfToken || session.cookies.csrf_token;
if (csrfToken) {
requestHeaders['X-CSRF-Token'] = csrfToken;
}
}
}
let payload = null;
if (data !== null && data !== undefined) {
payload = JSON.stringify(data);
requestHeaders['Content-Type'] = 'application/json';
requestHeaders['Content-Length'] = Buffer.byteLength(payload);
}
const req = transport.request({
protocol: url.protocol,
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method,
headers: requestHeaders
}, (res) => {
let body = '';
res.on('data', chunk => {
body += chunk;
});
res.on('end', () => {
if (session) {
storeSetCookies(session, res.headers['set-cookie']);
}
let parsed = body;
try {
parsed = body ? JSON.parse(body) : {};
} catch (e) {
// keep raw text
}
resolve({
status: res.statusCode,
data: parsed,
headers: res.headers
});
});
});
req.on('error', reject);
if (payload) {
req.write(payload);
}
req.end();
});
}
async function initCsrf(session) {
const res = await request('GET', '/api/csrf-token', {
session,
requireCsrf: false
});
if (res.status === 200 && res.data && res.data.csrfToken) {
session.csrfToken = res.data.csrfToken;
}
return res;
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function warn(message) {
testResults.warnings.push(message);
console.log(`[WARN] ${message}`);
}
async function test(name, fn) {
try {
await fn();
testResults.passed.push(name);
console.log(`[PASS] ${name}`);
} catch (error) {
testResults.failed.push({ name, error: error.message });
console.log(`[FAIL] ${name}: ${error.message}`);
}
}
function ensureTestUser() {
if (state.testUserId) {
return;
}
const suffix = Date.now();
const username = `admin_test_user_${suffix}`;
const email = `${username}@test.local`;
const password = `AdminTest#${suffix}`;
const id = UserDB.create({
username,
email,
password,
is_verified: 1
});
UserDB.update(id, {
is_active: 1,
is_banned: 0,
storage_permission: 'user_choice',
current_storage_type: 'oss'
});
state.testUserId = id;
}
function cleanupTestUser() {
if (!state.testUserId) return;
try {
UserDB.delete(state.testUserId);
} catch (error) {
warn(`清理测试用户失败: ${error.message}`);
} finally {
state.testUserId = null;
}
}
// 1. 安全检查:未认证访问应被拒绝
async function testUnauthorizedAccess() {
const res = await request('GET', '/api/admin/users');
assert(res.status === 401, `未认证访问应返回401,实际返回: ${res.status}`);
}
// 2. 安全检查:无效 Token 应被拒绝
async function testInvalidTokenAccess() {
const res = await request('GET', '/api/admin/users', {
headers: {
Authorization: 'Bearer invalid-token'
}
});
assert(res.status === 401, `无效token应返回401,实际: ${res.status}`);
}
// 3. 管理员登录(基于 Cookie
async function testAdminLogin() {
await initCsrf(state.adminSession);
const res = await request('POST', '/api/login', {
data: {
username: 'admin',
password: 'admin123'
},
session: state.adminSession,
requireCsrf: false
});
assert(res.status === 200, `登录应返回200,实际: ${res.status}`);
assert(res.data && res.data.success === true, `登录失败: ${res.data?.message || 'unknown'}`);
assert(!!state.adminSession.cookies.token, '登录后应写入 token Cookie');
await initCsrf(state.adminSession);
const profileRes = await request('GET', '/api/user/profile', {
session: state.adminSession
});
assert(profileRes.status === 200, `读取profile应返回200,实际: ${profileRes.status}`);
assert(profileRes.data?.success === true, '读取profile应成功');
assert(profileRes.data?.user?.is_admin === 1, '登录账号应为管理员');
state.adminUserId = profileRes.data.user.id;
assert(Number.isInteger(state.adminUserId) && state.adminUserId > 0, '应获取管理员ID');
}
// 4. 用户列表获取(分页)
async function testGetUsers() {
const res = await request('GET', '/api/admin/users?paged=1&page=1&pageSize=20&sort=created_desc', {
session: state.adminSession
});
assert(res.status === 200, `应返回200,实际: ${res.status}`);
assert(res.data?.success === true, '应返回success: true');
assert(Array.isArray(res.data?.users), 'users应为数组');
assert(!!res.data?.pagination, '分页模式应返回pagination');
}
// 5. 系统设置获取
async function testGetSettings() {
const res = await request('GET', '/api/admin/settings', {
session: state.adminSession
});
assert(res.status === 200, `应返回200,实际: ${res.status}`);
assert(res.data?.success === true, '应返回success: true');
assert(res.data?.settings && typeof res.data.settings === 'object', '应包含settings对象');
assert(res.data?.settings?.smtp && typeof res.data.settings.smtp === 'object', '应包含smtp配置');
assert(res.data?.settings?.global_theme !== undefined, '应包含全局主题设置');
state.latestSettings = res.data.settings;
}
// 6. 更新系统设置(写回当前值,避免影响测试环境)
async function testUpdateSettings() {
const current = state.latestSettings || {};
const payload = {
global_theme: current.global_theme || 'dark',
max_upload_size: Number(current.max_upload_size || 10737418240)
};
const res = await request('POST', '/api/admin/settings', {
data: payload,
session: state.adminSession
});
assert(res.status === 200, `应返回200,实际: ${res.status}`);
assert(res.data?.success === true, '应返回success: true');
}
// 7. 健康检查
async function testHealthCheck() {
const res = await request('GET', '/api/admin/health-check', {
session: state.adminSession
});
assert(res.status === 200, `应返回200,实际: ${res.status}`);
assert(res.data?.success === true, '应返回success: true');
assert(Array.isArray(res.data?.checks), '应包含checks数组');
assert(res.data?.summary && typeof res.data.summary === 'object', '应包含summary');
}
// 8. 存储统计
async function testStorageStats() {
const res = await request('GET', '/api/admin/storage-stats', {
session: state.adminSession
});
assert(res.status === 200, `应返回200,实际: ${res.status}`);
assert(res.data?.success === true, '应返回success: true');
assert(res.data?.stats && typeof res.data.stats === 'object', '应包含stats对象');
}
// 9. 系统日志获取
async function testGetLogs() {
const res = await request('GET', '/api/admin/logs?page=1&pageSize=10', {
session: state.adminSession
});
assert(res.status === 200, `应返回200,实际: ${res.status}`);
assert(res.data?.success === true, '应返回success: true');
assert(Array.isArray(res.data?.logs), 'logs应为数组');
}
// 10. 日志统计
async function testLogStats() {
const res = await request('GET', '/api/admin/logs/stats', {
session: state.adminSession
});
assert(res.status === 200, `应返回200,实际: ${res.status}`);
assert(res.data?.success === true, '应返回success: true');
assert(res.data?.stats && typeof res.data.stats === 'object', '应包含stats对象');
}
// 11. 分享列表获取
async function testGetShares() {
const res = await request('GET', '/api/admin/shares', {
session: state.adminSession
});
assert(res.status === 200, `应返回200,实际: ${res.status}`);
assert(res.data?.success === true, '应返回success: true');
assert(Array.isArray(res.data?.shares), 'shares应为数组');
}
// 12. 不能封禁自己
async function testCannotBanSelf() {
assert(state.adminUserId, '管理员ID未初始化');
const res = await request('POST', `/api/admin/users/${state.adminUserId}/ban`, {
data: { banned: true },
session: state.adminSession
});
assert(res.status === 400, `封禁自己应返回400,实际: ${res.status}`);
assert(String(res.data?.message || '').includes('不能封禁自己'), '应提示不能封禁自己');
}
// 13. 不能删除自己
async function testCannotDeleteSelf() {
assert(state.adminUserId, '管理员ID未初始化');
const res = await request('DELETE', `/api/admin/users/${state.adminUserId}`, {
session: state.adminSession
});
assert(res.status === 400, `删除自己应返回400,实际: ${res.status}`);
assert(String(res.data?.message || '').includes('不能删除自己'), '应提示不能删除自己');
}
// 14. 参数验证:无效用户ID
async function testInvalidUserId() {
const res = await request('POST', '/api/admin/users/invalid/ban', {
data: { banned: true },
session: state.adminSession
});
assert(res.status === 400, `无效用户ID应返回400,实际: ${res.status}`);
}
// 15. 参数验证:无效分享ID
async function testInvalidShareId() {
const res = await request('DELETE', '/api/admin/shares/invalid', {
session: state.adminSession
});
assert(res.status === 400, `无效分享ID应返回400,实际: ${res.status}`);
}
// 16. 存储权限设置
async function testSetStoragePermission() {
ensureTestUser();
const res = await request('POST', `/api/admin/users/${state.testUserId}/storage-permission`, {
data: {
storage_permission: 'local_only',
local_storage_quota: 2147483648,
download_traffic_quota: 3221225472
},
session: state.adminSession
});
assert(res.status === 200, `应返回200,实际: ${res.status}`);
assert(res.data?.success === true, '应返回success: true');
}
// 17. 参数验证:无效的存储权限值
async function testInvalidStoragePermission() {
ensureTestUser();
const res = await request('POST', `/api/admin/users/${state.testUserId}/storage-permission`, {
data: { storage_permission: 'invalid_permission' },
session: state.adminSession
});
assert(res.status === 400, `无效存储权限应返回400,实际: ${res.status}`);
}
// 18. 主题设置验证
async function testInvalidTheme() {
const res = await request('POST', '/api/admin/settings', {
data: { global_theme: 'invalid_theme' },
session: state.adminSession
});
assert(res.status === 400, `无效主题应返回400,实际: ${res.status}`);
}
// 19. 日志清理测试
async function testLogCleanup() {
const res = await request('POST', '/api/admin/logs/cleanup', {
data: { keepDays: 90 },
session: state.adminSession
});
assert(res.status === 200, `应返回200,实际: ${res.status}`);
assert(res.data?.success === true, '应返回success: true');
assert(typeof res.data?.deletedCount === 'number', 'deletedCount应为数字');
}
// 20. SMTP测试(未配置时返回400,配置后可能200/500)
async function testSmtpTest() {
const res = await request('POST', '/api/admin/settings/test-smtp', {
data: { to: 'test@example.com' },
session: state.adminSession
});
if (res.status === 400 && String(res.data?.message || '').includes('SMTP未配置')) {
console.log(' - SMTP未配置,这是预期的');
return;
}
assert(res.status === 200 || res.status === 500, `应返回200或500,实际: ${res.status}`);
}
// 21. 上传工具配置生成(替代已移除的 /api/admin/check-upload-tool
async function testGenerateUploadToolConfig() {
const res = await request('POST', '/api/upload/generate-tool', {
data: {},
session: state.adminSession
});
assert(res.status === 200, `应返回200,实际: ${res.status}`);
assert(res.data?.success === true, '应返回success: true');
assert(res.data?.config && typeof res.data.config === 'object', '应包含config对象');
assert(typeof res.data?.config?.api_key === 'string' && res.data.config.api_key.length > 0, '应返回有效api_key');
}
// 22. 用户文件查看 - 无效用户ID验证
async function testInvalidUserIdForFiles() {
const res = await request('GET', '/api/admin/users/invalid/files', {
session: state.adminSession
});
assert(res.status === 400, `无效用户ID应返回400,实际: ${res.status}`);
}
// 23. 删除用户 - 无效用户ID验证
async function testInvalidUserIdForDelete() {
const res = await request('DELETE', '/api/admin/users/invalid', {
session: state.adminSession
});
assert(res.status === 400, `无效用户ID应返回400,实际: ${res.status}`);
}
// 24. 存储权限设置 - 无效用户ID验证
async function testInvalidUserIdForPermission() {
const res = await request('POST', '/api/admin/users/invalid/storage-permission', {
data: {
storage_permission: 'local_only'
},
session: state.adminSession
});
assert(res.status === 400, `无效用户ID应返回400,实际: ${res.status}`);
}
async function runTests() {
console.log('========================================');
console.log('管理员功能完整性测试(Cookie + CSRF');
console.log('========================================\n');
console.log('\n--- 安全检查 ---');
await test('未认证访问应被拒绝', testUnauthorizedAccess);
await test('无效token应被拒绝', testInvalidTokenAccess);
await test('管理员登录', testAdminLogin);
console.log('\n--- 用户管理 ---');
await test('获取用户列表', testGetUsers);
await test('不能封禁自己', testCannotBanSelf);
await test('不能删除自己', testCannotDeleteSelf);
await test('无效用户ID验证', testInvalidUserId);
await test('设置存储权限', testSetStoragePermission);
await test('无效存储权限验证', testInvalidStoragePermission);
console.log('\n--- 系统设置 ---');
await test('获取系统设置', testGetSettings);
await test('更新系统设置', testUpdateSettings);
await test('无效主题验证', testInvalidTheme);
await test('SMTP测试', testSmtpTest);
console.log('\n--- 分享管理 ---');
await test('获取分享列表', testGetShares);
await test('无效分享ID验证', testInvalidShareId);
console.log('\n--- 系统监控 ---');
await test('健康检查', testHealthCheck);
await test('存储统计', testStorageStats);
await test('获取系统日志', testGetLogs);
await test('日志统计', testLogStats);
await test('日志清理', testLogCleanup);
console.log('\n--- 其他功能 ---');
await test('上传工具配置生成', testGenerateUploadToolConfig);
console.log('\n--- 参数验证增强 ---');
await test('用户文件查看无效ID验证', testInvalidUserIdForFiles);
await test('删除用户无效ID验证', testInvalidUserIdForDelete);
await test('存储权限设置无效ID验证', testInvalidUserIdForPermission);
cleanupTestUser();
console.log('\n========================================');
console.log('测试结果汇总');
console.log('========================================');
console.log(`通过: ${testResults.passed.length}`);
console.log(`失败: ${testResults.failed.length}`);
console.log(`警告: ${testResults.warnings.length}`);
if (testResults.failed.length > 0) {
console.log('\n失败的测试:');
for (const f of testResults.failed) {
console.log(` - ${f.name}: ${f.error}`);
}
}
if (testResults.warnings.length > 0) {
console.log('\n警告:');
for (const w of testResults.warnings) {
console.log(` - ${w}`);
}
}
console.log('\n========================================');
process.exit(testResults.failed.length > 0 ? 1 : 0);
}
runTests().catch((error) => {
cleanupTestUser();
console.error('测试执行异常:', error);
process.exit(1);
});
+105
View File
@@ -0,0 +1,105 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wanwanyun-download-quota-'));
const tempDbPath = path.join(tempDir, 'database.db');
process.env.DATABASE_PATH = tempDbPath;
process.env.ENCRYPTION_KEY = crypto.randomBytes(32).toString('hex');
process.env.JWT_SECRET = crypto.randomBytes(32).toString('hex');
process.env.REFRESH_SECRET = crypto.randomBytes(32).toString('hex');
process.env.WAL_CHECKPOINT_ENABLED = 'false';
let db;
try {
const { db: loadedDb, UserDB } = require('./database');
const { authMiddleware, generateToken } = require('./auth');
db = loadedDb;
const adminUser = UserDB.findByUsername(process.env.ADMIN_USERNAME || 'admin');
assert(adminUser, '应自动创建默认管理员账号');
assert(
Number(adminUser.download_traffic_quota) === -1,
`默认管理员下载配额应为 -1,实际: ${adminUser.download_traffic_quota}`
);
const username = `quota_test_${Date.now()}`;
const userId = UserDB.create({
username,
email: `${username}@example.com`,
password: 'secret123',
is_verified: 1
});
const createdUser = UserDB.findById(userId);
assert(createdUser, '新用户应创建成功');
assert(
Number(createdUser.download_traffic_quota) === -1,
`新用户默认下载配额应为 -1,实际: ${createdUser.download_traffic_quota}`
);
const token = generateToken(createdUser);
const req = {
headers: {
authorization: `Bearer ${token}`
},
cookies: {},
ip: '127.0.0.1',
socket: {
remoteAddress: '127.0.0.1'
},
get() {
return 'quota-test-agent';
}
};
let nextCalled = false;
const res = {
statusCode: 200,
payload: null,
status(code) {
this.statusCode = code;
return this;
},
json(body) {
this.payload = body;
return this;
}
};
authMiddleware(req, res, () => {
nextCalled = true;
});
assert(nextCalled, `authMiddleware 应放行不限流量用户,实际状态码: ${res.statusCode}`);
assert(req.user, 'authMiddleware 应写入 req.user');
assert(
Number(req.user.download_traffic_quota) === -1,
`authMiddleware 中的下载配额应保留 -1,实际: ${req.user.download_traffic_quota}`
);
console.log('PASS test_download_quota_defaults');
process.exit(0);
} catch (error) {
console.error('FAIL test_download_quota_defaults');
console.error(error && error.stack ? error.stack : error);
process.exit(1);
} finally {
if (db) {
try {
db.close();
} catch (closeError) {
console.error('关闭测试数据库失败:', closeError.message);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
const { ShareDB } = require('./database');
// 测试过期验证
console.log('=== 测试分享过期验证 ===\n');
// 获取所有分享
const { db } = require('./database');
const allShares = db.prepare('SELECT share_code, created_at, expires_at, datetime("now") as current_time FROM shares LIMIT 5').all();
console.log('数据库中的分享:');
allShares.forEach(share => {
console.log(`\n分享码: ${share.share_code}`);
console.log(`创建时间: ${share.created_at}`);
console.log(`到期时间: ${share.expires_at || '永久'}`);
console.log(`当前时间: ${share.current_time}`);
if (share.expires_at) {
const isExpired = share.expires_at <= share.current_time;
console.log(`是否过期: ${isExpired ? '是' : '否'}`);
}
// 测试findByCode
const found = ShareDB.findByCode(share.share_code);
console.log(`findByCode结果: ${found ? '找到' : '未找到(已过滤)'}`);
});
console.log('\n=== 测试完成 ===');
+863
View File
@@ -0,0 +1,863 @@
/**
* 分享功能完整性测试
*
* 测试范围:
* 1. 创建分享 - 单文件/文件夹/密码保护/过期时间
* 2. 访问分享 - 链接验证/密码验证/过期检查
* 3. 下载分享文件 - 单文件/多文件
* 4. 管理分享 - 查看/删除/统计
* 5. 边界条件 - 不存在/已过期/密码错误/文件已删除
*/
const http = require('http');
const https = require('https');
const { URL } = require('url');
// 测试配置
const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000';
const TEST_USERNAME = process.env.TEST_USERNAME || 'admin';
const TEST_PASSWORD = process.env.TEST_PASSWORD || 'admin123';
// 测试结果
const results = {
passed: 0,
failed: 0,
errors: []
};
// HTTP 请求工具
function request(method, path, data = null, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const isHttps = url.protocol === 'https:';
const lib = isHttps ? https : http;
// 确保端口号被正确解析
const port = url.port ? parseInt(url.port, 10) : (isHttps ? 443 : 80);
const options = {
hostname: url.hostname,
port: port,
path: url.pathname + url.search,
method: method,
headers: {
'Content-Type': 'application/json',
...headers
}
};
const req = lib.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try {
const json = JSON.parse(body);
resolve({ status: res.statusCode, data: json, headers: res.headers });
} catch (e) {
resolve({ status: res.statusCode, data: body, headers: res.headers });
}
});
});
req.on('error', reject);
if (data) {
req.write(JSON.stringify(data));
}
req.end();
});
}
// 测试工具
function assert(condition, message) {
if (condition) {
results.passed++;
console.log(` [PASS] ${message}`);
} else {
results.failed++;
results.errors.push(message);
console.log(` [FAIL] ${message}`);
}
}
// 保存 Cookie 的辅助函数
function extractCookies(headers) {
const cookies = [];
const setCookie = headers['set-cookie'];
if (setCookie) {
for (const cookie of setCookie) {
cookies.push(cookie.split(';')[0]);
}
}
return cookies.join('; ');
}
// 全局状态
let authCookie = '';
let testShareCode = '';
let testShareId = null;
let passwordShareCode = '';
let passwordShareId = null;
let expiryShareCode = '';
let directoryShareCode = '';
// ========== 测试用例 ==========
async function testLogin() {
console.log('\n[测试] 登录获取认证...');
try {
const res = await request('POST', '/api/login', {
username: TEST_USERNAME,
password: TEST_PASSWORD
});
assert(res.status === 200, `登录状态码应为 200, 实际: ${res.status}`);
assert(res.data.success === true, '登录应成功');
if (res.data.success) {
authCookie = extractCookies(res.headers);
console.log(` 认证Cookie已获取`);
}
return res.data.success;
} catch (error) {
console.log(` [ERROR] 登录失败: ${error.message}`);
results.failed++;
return false;
}
}
// ===== 1. 创建分享测试 =====
async function testCreateFileShare() {
console.log('\n[测试] 创建单文件分享...');
try {
const res = await request('POST', '/api/share/create', {
share_type: 'file',
file_path: '/test-file.txt',
file_name: 'test-file.txt'
}, { Cookie: authCookie });
assert(res.status === 200, `状态码应为 200, 实际: ${res.status}`);
assert(res.data.success === true, '创建分享应成功');
assert(res.data.share_code && res.data.share_code.length >= 8, '应返回有效的分享码');
assert(res.data.share_type === 'file', '分享类型应为 file');
if (res.data.success) {
testShareCode = res.data.share_code;
console.log(` 分享码: ${testShareCode}`);
}
return res.data.success;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
results.failed++;
return false;
}
}
async function testCreateDirectoryShare() {
console.log('\n[测试] 创建文件夹分享...');
try {
const res = await request('POST', '/api/share/create', {
share_type: 'directory',
file_path: '/test-folder'
}, { Cookie: authCookie });
assert(res.status === 200, `状态码应为 200, 实际: ${res.status}`);
assert(res.data.success === true, '创建文件夹分享应成功');
assert(res.data.share_type === 'directory', '分享类型应为 directory');
if (res.data.success) {
directoryShareCode = res.data.share_code;
console.log(` 分享码: ${directoryShareCode}`);
}
return res.data.success;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
results.failed++;
return false;
}
}
async function testCreatePasswordShare() {
console.log('\n[测试] 创建密码保护分享...');
try {
const res = await request('POST', '/api/share/create', {
share_type: 'file',
file_path: '/test-file-password.txt',
file_name: 'test-file-password.txt',
password: 'test123'
}, { Cookie: authCookie });
assert(res.status === 200, `状态码应为 200, 实际: ${res.status}`);
assert(res.data.success === true, '创建密码保护分享应成功');
if (res.data.success) {
passwordShareCode = res.data.share_code;
console.log(` 分享码: ${passwordShareCode}`);
}
return res.data.success;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
results.failed++;
return false;
}
}
async function testCreateExpiryShare() {
console.log('\n[测试] 创建带过期时间的分享...');
try {
const res = await request('POST', '/api/share/create', {
share_type: 'file',
file_path: '/test-file-expiry.txt',
file_name: 'test-file-expiry.txt',
expiry_days: 7
}, { Cookie: authCookie });
assert(res.status === 200, `状态码应为 200, 实际: ${res.status}`);
assert(res.data.success === true, '创建带过期时间分享应成功');
assert(res.data.expires_at !== null, '应返回过期时间');
if (res.data.success) {
expiryShareCode = res.data.share_code;
console.log(` 分享码: ${expiryShareCode}`);
console.log(` 过期时间: ${res.data.expires_at}`);
}
return res.data.success;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
results.failed++;
return false;
}
}
async function testCreateShareValidation() {
console.log('\n[测试] 创建分享参数验证...');
// 测试无效的分享类型
try {
const res1 = await request('POST', '/api/share/create', {
share_type: 'invalid_type',
file_path: '/test.txt'
}, { Cookie: authCookie });
assert(res1.status === 400, '无效分享类型应返回 400');
assert(res1.data.success === false, '无效分享类型应失败');
} catch (error) {
console.log(` [ERROR] 测试无效分享类型: ${error.message}`);
}
// 测试空路径
try {
const res2 = await request('POST', '/api/share/create', {
share_type: 'file',
file_path: ''
}, { Cookie: authCookie });
assert(res2.status === 400, '空路径应返回 400');
assert(res2.data.success === false, '空路径应失败');
} catch (error) {
console.log(` [ERROR] 测试空路径: ${error.message}`);
}
// 测试无效的过期天数
try {
const res3 = await request('POST', '/api/share/create', {
share_type: 'file',
file_path: '/test.txt',
expiry_days: 0
}, { Cookie: authCookie });
assert(res3.status === 400, '无效过期天数应返回 400');
} catch (error) {
console.log(` [ERROR] 测试无效过期天数: ${error.message}`);
}
// 测试过长密码
try {
const res4 = await request('POST', '/api/share/create', {
share_type: 'file',
file_path: '/test.txt',
password: 'a'.repeat(100)
}, { Cookie: authCookie });
assert(res4.status === 400, '过长密码应返回 400');
} catch (error) {
console.log(` [ERROR] 测试过长密码: ${error.message}`);
}
// 测试路径遍历攻击
try {
const res5 = await request('POST', '/api/share/create', {
share_type: 'file',
file_path: '../../../etc/passwd'
}, { Cookie: authCookie });
assert(res5.status === 400, '路径遍历攻击应返回 400');
} catch (error) {
console.log(` [ERROR] 测试路径遍历: ${error.message}`);
}
}
// ===== 2. 访问分享测试 =====
async function testVerifyShareNoPassword() {
console.log('\n[测试] 验证无密码分享...');
if (!testShareCode) {
console.log(' [SKIP] 无测试分享码');
return false;
}
try {
const res = await request('POST', `/api/share/${testShareCode}/verify`, {});
// 注意: 如果文件不存在,可能返回 500
// 这里我们主要测试 API 逻辑
if (res.status === 500 && res.data.message && res.data.message.includes('不存在')) {
console.log(' [INFO] 测试文件不存在 (预期行为,需创建测试文件)');
assert(true, '文件不存在时返回适当错误');
return true;
}
assert(res.status === 200, `状态码应为 200, 实际: ${res.status}`);
assert(res.data.success === true, '验证应成功');
if (res.data.share) {
assert(res.data.share.share_type === 'file', '分享类型应正确');
assert(res.data.share.share_path, '应返回分享路径');
}
return res.data.success;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
results.failed++;
return false;
}
}
async function testVerifyShareWithPassword() {
console.log('\n[测试] 验证需要密码的分享...');
if (!passwordShareCode) {
console.log(' [SKIP] 无密码保护分享码');
return false;
}
// 测试不提供密码
try {
const res1 = await request('POST', `/api/share/${passwordShareCode}/verify`, {});
assert(res1.status === 401, '无密码应返回 401');
assert(res1.data.needPassword === true, '应提示需要密码');
} catch (error) {
console.log(` [ERROR] 测试无密码访问: ${error.message}`);
}
// 测试错误密码
try {
const res2 = await request('POST', `/api/share/${passwordShareCode}/verify`, {
password: 'wrong_password'
});
assert(res2.status === 401, '错误密码应返回 401');
assert(res2.data.message === '密码错误', '应提示密码错误');
} catch (error) {
console.log(` [ERROR] 测试错误密码: ${error.message}`);
}
// 测试正确密码
try {
const res3 = await request('POST', `/api/share/${passwordShareCode}/verify`, {
password: 'test123'
});
// 如果文件存在
if (res3.status === 200) {
assert(res3.data.success === true, '正确密码应验证成功');
} else if (res3.status === 500 && res3.data.message && res3.data.message.includes('不存在')) {
console.log(' [INFO] 密码验证通过,但文件不存在');
assert(true, '密码验证逻辑正确');
}
} catch (error) {
console.log(` [ERROR] 测试正确密码: ${error.message}`);
}
return true;
}
async function testVerifyShareNotFound() {
console.log('\n[测试] 访问不存在的分享...');
try {
const res = await request('POST', '/api/share/nonexistent123/verify', {});
assert(res.status === 404, `状态码应为 404, 实际: ${res.status}`);
assert(res.data.success === false, '应返回失败');
assert(res.data.message === '分享不存在', '应提示分享不存在');
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
results.failed++;
return false;
}
}
async function testGetShareTheme() {
console.log('\n[测试] 获取分享主题...');
if (!testShareCode) {
console.log(' [SKIP] 无测试分享码');
return false;
}
try {
const res = await request('GET', `/api/share/${testShareCode}/theme`);
assert(res.status === 200, `状态码应为 200, 实际: ${res.status}`);
assert(res.data.success === true, '获取主题应成功');
assert(['dark', 'light'].includes(res.data.theme), '主题应为 dark 或 light');
console.log(` 主题: ${res.data.theme}`);
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
results.failed++;
return false;
}
}
// ===== 3. 下载分享文件测试 =====
async function testGetDownloadUrl() {
console.log('\n[测试] 获取下载链接...');
if (!testShareCode) {
console.log(' [SKIP] 无测试分享码');
return false;
}
try {
const res = await request('POST', `/api/share/${testShareCode}/download-url`, { path: '/test-file.txt' });
// 如果文件存在
if (res.status === 200) {
assert(res.data.success === true, '获取下载链接应成功');
assert(res.data.downloadUrl, '应返回下载链接');
console.log(` 下载方式: ${res.data.direct ? 'OSS直连' : '后端代理'}`);
} else if (res.status === 404) {
console.log(' [INFO] 分享不存在或已过期');
assert(true, '分享不存在时返回 404');
} else if (res.status === 403) {
console.log(' [INFO] 路径验证失败 (预期行为)');
assert(true, '路径不在分享范围内返回 403');
}
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
results.failed++;
return false;
}
}
async function testDownloadWithPassword() {
console.log('\n[测试] 带密码下载...');
if (!passwordShareCode) {
console.log(' [SKIP] 无密码保护分享码');
return false;
}
// 测试无密码
try {
const res1 = await request('POST', `/api/share/${passwordShareCode}/download-url`, { path: '/test-file-password.txt' });
assert(res1.status === 401, '无密码应返回 401');
} catch (error) {
console.log(` [ERROR] 测试无密码下载: ${error.message}`);
}
// 测试带密码
try {
const res2 = await request('POST', `/api/share/${passwordShareCode}/download-url`, { path: '/test-file-password.txt', password: 'test123' });
// 密码正确,根据文件是否存在返回不同结果
if (res2.status === 200) {
assert(res2.data.downloadUrl, '应返回下载链接');
} else {
console.log(` [INFO] 状态码: ${res2.status}, 消息: ${res2.data.message}`);
}
} catch (error) {
console.log(` [ERROR] 测试带密码下载: ${error.message}`);
}
return true;
}
async function testRecordDownload() {
console.log('\n[测试] 记录下载次数...');
if (!testShareCode) {
console.log(' [SKIP] 无测试分享码');
return false;
}
try {
const res = await request('POST', `/api/share/${testShareCode}/download`, {});
assert(res.status === 200, `状态码应为 200, 实际: ${res.status}`);
assert(res.data.success === true, '记录下载应成功');
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
results.failed++;
return false;
}
}
async function testDownloadPathValidation() {
console.log('\n[测试] 下载路径验证 (防越权)...');
if (!testShareCode) {
console.log(' [SKIP] 无测试分享码');
return false;
}
// 测试越权访问
try {
const res = await request('POST', `/api/share/${testShareCode}/download-url`, { path: '/other-file.txt' });
// 单文件分享应该禁止访问其他文件
assert(res.status === 403 || res.status === 404, '越权访问应被拒绝');
console.log(` 越权访问返回状态码: ${res.status}`);
} catch (error) {
console.log(` [ERROR] ${error.message}`);
}
// 测试路径遍历
try {
const res2 = await request('POST', `/api/share/${testShareCode}/download-url`, { path: '/../../../etc/passwd' });
assert(res2.status === 403 || res2.status === 400, '路径遍历应被拒绝');
} catch (error) {
console.log(` [ERROR] 路径遍历测试: ${error.message}`);
}
return true;
}
// ===== 4. 管理分享测试 =====
async function testGetMyShares() {
console.log('\n[测试] 获取我的分享列表...');
try {
const res = await request('GET', '/api/share/my', null, { Cookie: authCookie });
assert(res.status === 200, `状态码应为 200, 实际: ${res.status}`);
assert(res.data.success === true, '获取分享列表应成功');
assert(Array.isArray(res.data.shares), '应返回分享数组');
console.log(` 分享数量: ${res.data.shares.length}`);
// 查找我们创建的测试分享
if (testShareCode) {
const testShare = res.data.shares.find(s => s.share_code === testShareCode);
if (testShare) {
testShareId = testShare.id;
console.log(` 找到测试分享 ID: ${testShareId}`);
}
}
if (passwordShareCode) {
const pwShare = res.data.shares.find(s => s.share_code === passwordShareCode);
if (pwShare) {
passwordShareId = pwShare.id;
}
}
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
results.failed++;
return false;
}
}
async function testDeleteShare() {
console.log('\n[测试] 删除分享...');
// 先创建一个用于删除测试的分享
try {
const createRes = await request('POST', '/api/share/create', {
share_type: 'file',
file_path: '/delete-test.txt'
}, { Cookie: authCookie });
if (!createRes.data.success) {
console.log(' [SKIP] 无法创建测试分享');
return false;
}
// 获取分享ID
const mySharesRes = await request('GET', '/api/share/my', null, { Cookie: authCookie });
const deleteShare = mySharesRes.data.shares.find(s => s.share_code === createRes.data.share_code);
if (!deleteShare) {
console.log(' [SKIP] 找不到测试分享');
return false;
}
// 删除分享
const deleteRes = await request('DELETE', `/api/share/${deleteShare.id}`, null, { Cookie: authCookie });
assert(deleteRes.status === 200, `删除状态码应为 200, 实际: ${deleteRes.status}`);
assert(deleteRes.data.success === true, '删除应成功');
// 验证已删除
const verifyRes = await request('POST', `/api/share/${createRes.data.share_code}/verify`, {});
assert(verifyRes.status === 404, '已删除分享应返回 404');
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
results.failed++;
return false;
}
}
async function testDeleteShareValidation() {
console.log('\n[测试] 删除分享权限验证...');
// 测试删除不存在的分享
try {
const res1 = await request('DELETE', '/api/share/99999999', null, { Cookie: authCookie });
assert(res1.status === 404, '删除不存在的分享应返回 404');
} catch (error) {
console.log(` [ERROR] 测试删除不存在: ${error.message}`);
}
// 测试无效的分享ID
try {
const res2 = await request('DELETE', '/api/share/invalid', null, { Cookie: authCookie });
assert(res2.status === 400, '无效ID应返回 400');
} catch (error) {
console.log(` [ERROR] 测试无效ID: ${error.message}`);
}
return true;
}
// ===== 5. 边界条件测试 =====
async function testShareNotExists() {
console.log('\n[测试] 分享不存在场景...');
const nonExistentCode = 'XXXXXXXXXX';
// 验证
try {
const res1 = await request('POST', `/api/share/${nonExistentCode}/verify`, {});
assert(res1.status === 404, '验证不存在分享应返回 404');
} catch (error) {
console.log(` [ERROR] ${error.message}`);
}
// 获取文件列表
try {
const res2 = await request('POST', `/api/share/${nonExistentCode}/list`, {});
assert(res2.status === 404, '获取列表不存在分享应返回 404');
} catch (error) {
console.log(` [ERROR] ${error.message}`);
}
// 下载
try {
const res3 = await request('POST', `/api/share/${nonExistentCode}/download-url`, { path: '/test.txt' });
assert(res3.status === 404, '下载不存在分享应返回 404');
} catch (error) {
console.log(` [ERROR] ${error.message}`);
}
return true;
}
async function testShareExpired() {
console.log('\n[测试] 分享已过期场景...');
// 注意: 需要直接操作数据库创建过期分享才能完整测试
// 这里我们测试 API 对过期检查的处理逻辑
console.log(' [INFO] 过期检查在 ShareDB.findByCode 中实现');
console.log(' [INFO] 使用 SQL: expires_at IS NULL OR expires_at > datetime(\'now\', \'localtime\')');
assert(true, '过期检查逻辑已实现');
return true;
}
async function testPasswordErrors() {
console.log('\n[测试] 密码错误场景...');
if (!passwordShareCode) {
console.log(' [SKIP] 无密码保护分享码');
return false;
}
// 多次错误密码尝试 (测试限流)
for (let i = 0; i < 3; i++) {
try {
const res = await request('POST', `/api/share/${passwordShareCode}/verify`, {
password: `wrong${i}`
});
if (i < 2) {
assert(res.status === 401, `${i+1}次错误密码应返回 401`);
} else {
// 可能触发限流
console.log(`${i+1}次尝试状态码: ${res.status}`);
}
} catch (error) {
console.log(` [ERROR] 第${i+1}次尝试: ${error.message}`);
}
}
return true;
}
async function testFileDeleted() {
console.log('\n[测试] 文件已删除场景...');
// 当分享的文件被删除时,验证接口应该返回适当错误
console.log(' [INFO] 文件删除检查在 verify 接口的存储查询中实现');
console.log(' [INFO] 当 fileInfo 不存在时抛出 "分享的文件已被删除或不存在" 错误');
assert(true, '文件删除检查逻辑已实现');
return true;
}
async function testRateLimiting() {
console.log('\n[测试] 访问限流...');
// 快速发送多个请求测试限流
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(request('POST', '/api/share/test123/verify', {}));
}
const results = await Promise.all(promises);
const rateLimited = results.some(r => r.status === 429);
console.log(` 发送10个并发请求,限流触发: ${rateLimited ? '是' : '否'}`);
// 限流不是必须触发的,取决于配置
assert(true, '限流机制已实现 (shareRateLimitMiddleware)');
return true;
}
// ===== 清理测试数据 =====
async function cleanup() {
console.log('\n[清理] 删除测试分享...');
const sharesToDelete = [testShareId, passwordShareId].filter(id => id);
for (const id of sharesToDelete) {
try {
await request('DELETE', `/api/share/${id}`, null, { Cookie: authCookie });
console.log(` 已删除分享 ID: ${id}`);
} catch (error) {
console.log(` [WARN] 清理分享 ${id} 失败: ${error.message}`);
}
}
}
// ===== 主测试流程 =====
async function runTests() {
console.log('========================================');
console.log(' 分享功能完整性测试');
console.log('========================================');
console.log(`测试服务器: ${BASE_URL}`);
console.log(`测试用户: ${TEST_USERNAME}`);
// 登录
const loggedIn = await testLogin();
if (!loggedIn) {
console.log('\n[FATAL] 登录失败,无法继续测试');
return;
}
// 1. 创建分享测试
console.log('\n======== 1. 创建分享测试 ========');
await testCreateFileShare();
await testCreateDirectoryShare();
await testCreatePasswordShare();
await testCreateExpiryShare();
await testCreateShareValidation();
// 2. 访问分享测试
console.log('\n======== 2. 访问分享测试 ========');
await testVerifyShareNoPassword();
await testVerifyShareWithPassword();
await testVerifyShareNotFound();
await testGetShareTheme();
// 3. 下载分享文件测试
console.log('\n======== 3. 下载分享文件测试 ========');
await testGetDownloadUrl();
await testDownloadWithPassword();
await testRecordDownload();
await testDownloadPathValidation();
// 4. 管理分享测试
console.log('\n======== 4. 管理分享测试 ========');
await testGetMyShares();
await testDeleteShare();
await testDeleteShareValidation();
// 5. 边界条件测试
console.log('\n======== 5. 边界条件测试 ========');
await testShareNotExists();
await testShareExpired();
await testPasswordErrors();
await testFileDeleted();
await testRateLimiting();
// 清理
await cleanup();
// 结果统计
console.log('\n========================================');
console.log(' 测试结果统计');
console.log('========================================');
console.log(`通过: ${results.passed}`);
console.log(`失败: ${results.failed}`);
if (results.errors.length > 0) {
console.log('\n失败的测试:');
results.errors.forEach((err, i) => {
console.log(` ${i + 1}. ${err}`);
});
}
console.log('\n========================================');
// 返回退出码
process.exit(results.failed > 0 ? 1 : 0);
}
// 运行测试
runTests().catch(error => {
console.error('测试执行失败:', error);
process.exit(1);
});
+614
View File
@@ -0,0 +1,614 @@
/**
* 分享功能边界条件深度测试(兼容 Cookie + CSRF
*
* 测试场景:
* 1. 已过期的分享
* 2. 分享文件不存在
* 3. 被封禁用户的分享
* 4. 路径遍历攻击
* 5. 特殊字符路径
* 6. 并发密码尝试
* 7. 分享统计
* 8. 分享码唯一性
* 9. 过期时间格式
*/
const http = require('http');
const https = require('https');
const bcrypt = require('bcryptjs');
const { db, ShareDB, UserDB } = require('./database');
const BASE_URL = process.env.TEST_BASE_URL || 'http://127.0.0.1:40001';
const results = {
passed: 0,
failed: 0,
errors: []
};
const adminSession = {
cookies: {},
csrfToken: ''
};
let adminUserId = 1;
function makeCookieHeader(cookies) {
return Object.entries(cookies || {})
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
function storeSetCookies(session, setCookieHeader) {
if (!session || !setCookieHeader) return;
const list = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
for (const raw of list) {
const first = String(raw || '').split(';')[0];
const idx = first.indexOf('=');
if (idx <= 0) continue;
const key = first.slice(0, idx).trim();
const value = first.slice(idx + 1).trim();
session.cookies[key] = value;
if (key === 'csrf_token') {
session.csrfToken = value;
}
}
}
function isSafeMethod(method) {
const upper = String(method || '').toUpperCase();
return upper === 'GET' || upper === 'HEAD' || upper === 'OPTIONS';
}
function request(method, path, options = {}) {
const {
data = null,
session = null,
headers = {},
requireCsrf = true
} = options;
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const transport = url.protocol === 'https:' ? https : http;
const requestHeaders = { ...headers };
if (session) {
const cookieHeader = makeCookieHeader(session.cookies);
if (cookieHeader) {
requestHeaders.Cookie = cookieHeader;
}
if (requireCsrf && !isSafeMethod(method)) {
const csrfToken = session.csrfToken || session.cookies.csrf_token;
if (csrfToken) {
requestHeaders['X-CSRF-Token'] = csrfToken;
}
}
}
let payload = null;
if (data !== null && data !== undefined) {
payload = JSON.stringify(data);
requestHeaders['Content-Type'] = 'application/json';
requestHeaders['Content-Length'] = Buffer.byteLength(payload);
}
const req = transport.request({
protocol: url.protocol,
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method,
headers: requestHeaders
}, (res) => {
let body = '';
res.on('data', chunk => {
body += chunk;
});
res.on('end', () => {
if (session) {
storeSetCookies(session, res.headers['set-cookie']);
}
let parsed = body;
try {
parsed = body ? JSON.parse(body) : {};
} catch (e) {
// keep raw text
}
resolve({
status: res.statusCode,
data: parsed,
headers: res.headers
});
});
});
req.on('error', reject);
if (payload) {
req.write(payload);
}
req.end();
});
}
async function initCsrf(session) {
const res = await request('GET', '/api/csrf-token', {
session,
requireCsrf: false
});
if (res.status === 200 && res.data && res.data.csrfToken) {
session.csrfToken = res.data.csrfToken;
}
return res;
}
function assert(condition, message) {
if (condition) {
results.passed++;
console.log(` [PASS] ${message}`);
} else {
results.failed++;
results.errors.push(message);
console.log(` [FAIL] ${message}`);
}
}
function createValidShareCode(prefix = '') {
for (let i = 0; i < 20; i++) {
const generated = ShareDB.generateShareCode();
const code = (prefix + generated).replace(/[^A-Za-z0-9]/g, '').slice(0, 16);
if (!code || code.length < 6) continue;
const exists = db.prepare('SELECT 1 FROM shares WHERE share_code = ?').get(code);
if (!exists) return code;
}
return ShareDB.generateShareCode();
}
async function testExpiredShare() {
console.log('\n[测试] 已过期的分享...');
const expiredShareCode = createValidShareCode('E');
try {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const expiresAt = yesterday.toISOString().replace('T', ' ').substring(0, 19);
db.prepare(`
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(adminUserId, expiredShareCode, '/expired-test.txt', 'file', 'local', expiresAt);
console.log(` 创建过期分享: ${expiredShareCode}, 过期时间: ${expiresAt}`);
const res = await request('POST', `/api/share/${expiredShareCode}/verify`, {
data: {},
requireCsrf: false
});
assert(res.status === 404, `过期分享应返回 404, 实际: ${res.status}`);
assert(res.data && res.data.message === '分享不存在', '应提示分享不存在');
db.prepare('DELETE FROM shares WHERE share_code = ?').run(expiredShareCode);
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
results.failed++;
db.prepare('DELETE FROM shares WHERE share_code = ?').run(expiredShareCode);
return false;
}
}
async function testShareWithDeletedFile() {
console.log('\n[测试] 分享的文件不存在...');
const shareCode = createValidShareCode('N');
try {
db.prepare(`
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type)
VALUES (?, ?, ?, ?, ?)
`).run(adminUserId, shareCode, '/non_existent_file_xyz.txt', 'file', 'local');
console.log(` 创建分享: ${shareCode}, 路径: /non_existent_file_xyz.txt`);
const res = await request('POST', `/api/share/${shareCode}/verify`, {
data: {},
requireCsrf: false
});
assert(res.status === 500 || res.status === 200, `应返回500或200,实际: ${res.status}`);
if (res.status === 500) {
assert(!!res.data?.message, '500时应返回错误消息');
}
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
return false;
}
}
async function testShareByBannedUser() {
console.log('\n[测试] 被封禁用户的分享...');
let testUserId = null;
const shareCode = createValidShareCode('B');
try {
testUserId = UserDB.create({
username: `test_banned_${Date.now()}`,
email: `test_banned_${Date.now()}@test.com`,
password: 'test123',
is_verified: 1
});
db.prepare(`
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type)
VALUES (?, ?, ?, ?, ?)
`).run(testUserId, shareCode, '/test.txt', 'file', 'local');
console.log(` 创建测试用户 ID: ${testUserId}`);
console.log(` 创建分享: ${shareCode}`);
UserDB.setBanStatus(testUserId, true);
console.log(` 封禁用户: ${testUserId}`);
const res = await request('POST', `/api/share/${shareCode}/verify`, {
data: {},
requireCsrf: false
});
console.log(` 被封禁用户分享访问状态码: ${res.status}`);
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
UserDB.delete(testUserId);
assert(true, '被封禁用户分享测试完成');
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
if (shareCode) db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
if (testUserId) UserDB.delete(testUserId);
return false;
}
}
async function testPathTraversalAttacks() {
console.log('\n[测试] 路径遍历攻击防护...');
const shareCode = createValidShareCode('T');
try {
db.prepare(`
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type)
VALUES (?, ?, ?, ?, ?)
`).run(adminUserId, shareCode, '/allowed-folder', 'directory', 'local');
const attackPaths = [
'../../../etc/passwd',
'..\\..\\..\\etc\\passwd',
'%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd',
'/allowed-folder/../../../etc/passwd',
'/allowed-folder/./../../etc/passwd',
'....//....//....//etc/passwd',
'/allowed-folder%00.txt/../../../etc/passwd'
];
let blocked = 0;
for (const attackPath of attackPaths) {
const res = await request('POST', `/api/share/${shareCode}/download-url`, {
data: { path: attackPath },
requireCsrf: false
});
if (res.status === 403 || res.status === 400) {
blocked++;
console.log(` [BLOCKED] ${attackPath.substring(0, 40)}...`);
} else {
console.log(` [WARN] 可能未阻止: ${attackPath}, 状态: ${res.status}`);
}
}
assert(blocked >= attackPaths.length - 1, `路径遍历攻击应被阻止 (${blocked}/${attackPaths.length})`);
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
return false;
}
}
async function testSpecialCharactersInPath() {
console.log('\n[测试] 特殊字符路径处理...');
if (!adminSession.cookies.token) {
console.log(' [WARN] 未登录,跳过特殊字符路径测试');
assert(true, '未登录时跳过特殊字符路径测试');
return true;
}
const specialPaths = [
'/文件夹/中文文件.txt',
'/folder with spaces/file.txt',
'/folder-with-dashes/file_underscore.txt',
'/folder.with.dots/file.name.ext.txt',
"/folder'with'quotes/file.txt"
];
let handled = 0;
for (const virtualPath of specialPaths) {
try {
const createRes = await request('POST', '/api/share/create', {
data: {
share_type: 'file',
file_path: virtualPath
},
session: adminSession
});
if (createRes.status === 200 || createRes.status === 400) {
handled++;
console.log(` [OK] ${virtualPath.substring(0, 30)}... - 状态: ${createRes.status}`);
if (createRes.status === 200 && createRes.data?.share_code) {
const mySharesRes = await request('GET', '/api/share/my', {
session: adminSession
});
const share = mySharesRes.data?.shares?.find(s => s.share_code === createRes.data.share_code);
if (share) {
await request('DELETE', `/api/share/${share.id}`, {
session: adminSession
});
}
}
}
} catch (error) {
console.log(` [ERROR] ${virtualPath}: ${error.message}`);
}
}
assert(handled === specialPaths.length, '特殊字符路径处理完成');
return true;
}
async function testConcurrentPasswordAttempts() {
console.log('\n[测试] 并发密码尝试限流...');
const shareCode = createValidShareCode('C');
try {
const hashedPassword = bcrypt.hashSync('correct123', 10);
db.prepare(`
INSERT INTO shares (user_id, share_code, share_path, share_type, share_password, storage_type)
VALUES (?, ?, ?, ?, ?, ?)
`).run(adminUserId, shareCode, '/test.txt', 'file', hashedPassword, 'local');
const promises = [];
for (let i = 0; i < 20; i++) {
promises.push(
request('POST', `/api/share/${shareCode}/verify`, {
data: { password: `wrong${i}` },
requireCsrf: false
})
);
}
const responses = await Promise.all(promises);
const rateLimited = responses.filter(r => r.status === 429).length;
const unauthorized = responses.filter(r => r.status === 401).length;
console.log(` 并发请求: 20, 限流: ${rateLimited}, 401错误: ${unauthorized}`);
if (rateLimited > 0) {
assert(true, '限流机制生效');
} else {
console.log(' [INFO] 限流未触发(可能配置较宽松)');
assert(true, '并发测试完成');
}
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
return false;
}
}
async function testShareStatistics() {
console.log('\n[测试] 分享统计功能...');
const shareCode = createValidShareCode('S');
try {
db.prepare(`
INSERT INTO shares (user_id, share_code, share_path, share_type, storage_type, view_count, download_count)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(adminUserId, shareCode, '/test.txt', 'file', 'local', 0, 0);
for (let i = 0; i < 3; i++) {
await request('POST', `/api/share/${shareCode}/verify`, {
data: {},
requireCsrf: false
});
}
for (let i = 0; i < 2; i++) {
await request('POST', `/api/share/${shareCode}/download`, {
data: {},
requireCsrf: false
});
}
const share = db.prepare('SELECT view_count, download_count FROM shares WHERE share_code = ?').get(shareCode);
assert(share.view_count === 3, `查看次数应为 3, 实际: ${share.view_count}`);
assert(share.download_count === 2, `下载次数应为 2, 实际: ${share.download_count}`);
console.log(` 查看次数: ${share.view_count}, 下载次数: ${share.download_count}`);
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
db.prepare('DELETE FROM shares WHERE share_code = ?').run(shareCode);
return false;
}
}
async function testShareCodeUniqueness() {
console.log('\n[测试] 分享码唯一性...');
try {
const codes = new Set();
for (let i = 0; i < 10; i++) {
const code = ShareDB.generateShareCode();
if (codes.has(code)) {
console.log(` [WARN] 发现重复分享码: ${code}`);
}
codes.add(code);
}
assert(codes.size === 10, `应生成 10 个唯一分享码, 实际: ${codes.size}`);
console.log(` 生成了 ${codes.size} 个唯一分享码`);
const sampleCode = ShareDB.generateShareCode();
assert(sampleCode.length === 8, `分享码长度应为 8, 实际: ${sampleCode.length}`);
assert(/^[a-zA-Z0-9]+$/.test(sampleCode), '分享码应只包含字母数字');
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
return false;
}
}
async function testExpiryTimeFormat() {
console.log('\n[测试] 过期时间格式...');
try {
const testDays = [1, 7, 30, 365];
for (const days of testDays) {
const result = ShareDB.create(adminUserId, {
share_type: 'file',
file_path: `/test_${days}_days.txt`,
expiry_days: days
});
const share = db.prepare('SELECT expires_at FROM shares WHERE share_code = ?').get(result.share_code);
const expiresAt = new Date(share.expires_at);
const now = new Date();
const diffDays = Math.round((expiresAt - now) / (1000 * 60 * 60 * 24));
assert(Math.abs(diffDays - days) <= 1, `${days}天过期应正确设置, 实际差异: ${diffDays}`);
db.prepare('DELETE FROM shares WHERE share_code = ?').run(result.share_code);
}
return true;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
return false;
}
}
async function login() {
console.log('\n[准备] 登录获取认证...');
try {
await initCsrf(adminSession);
const res = await request('POST', '/api/login', {
data: {
username: 'admin',
password: 'admin123'
},
session: adminSession,
requireCsrf: false
});
if (res.status === 200 && res.data?.success && adminSession.cookies.token) {
await initCsrf(adminSession);
const profileRes = await request('GET', '/api/user/profile', {
session: adminSession
});
if (profileRes.status === 200 && profileRes.data?.user?.id) {
adminUserId = profileRes.data.user.id;
}
console.log(' 认证成功');
return true;
}
console.log(` 认证失败: status=${res.status}, message=${res.data?.message || 'unknown'}`);
return false;
} catch (error) {
console.log(` [ERROR] ${error.message}`);
return false;
}
}
async function runTests() {
console.log('========================================');
console.log(' 分享功能边界条件深度测试(Cookie + CSRF');
console.log('========================================');
const loggedIn = await login();
if (!loggedIn) {
console.log('\n[WARN] 登录失败,部分测试可能无法执行');
}
await testExpiredShare();
await testShareWithDeletedFile();
await testShareByBannedUser();
await testPathTraversalAttacks();
await testSpecialCharactersInPath();
await testConcurrentPasswordAttempts();
await testShareStatistics();
await testShareCodeUniqueness();
await testExpiryTimeFormat();
console.log('\n========================================');
console.log(' 测试结果统计');
console.log('========================================');
console.log(`通过: ${results.passed}`);
console.log(`失败: ${results.failed}`);
if (results.errors.length > 0) {
console.log('\n失败的测试:');
results.errors.forEach((err, i) => {
console.log(` ${i + 1}. ${err}`);
});
}
console.log('\n========================================');
process.exit(results.failed > 0 ? 1 : 0);
}
runTests().catch(error => {
console.error('测试执行失败:', error);
process.exit(1);
});
+186
View File
@@ -0,0 +1,186 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const projectRoot = path.resolve(__dirname, '..', '..');
const serverPath = path.join(projectRoot, 'backend', 'server.js');
const source = fs.readFileSync(serverPath, 'utf8');
const results = { passed: 0, failed: 0 };
function test(name, fn) {
try {
fn();
results.passed += 1;
console.log(` [PASS] ${name}`);
} catch (error) {
results.failed += 1;
console.error(` [FAIL] ${name}: ${error.message}`);
}
}
console.log('\n========== 架构约束测试 ==========\n');
test('Express async 错误补丁在所有路由之前加载', () => {
const patchIndex = source.indexOf("require('express-async-errors')");
const routeIndex = source.search(/\bapp\.(get|post|put|delete|patch)\s*\(/);
assert.ok(patchIndex >= 0);
assert.ok(routeIndex > patchIndex);
});
test('全局错误中间件保持为最后一个 app.use', () => {
const errorHandlerIndex = source.indexOf('app.use(expressErrorHandler);');
assert.ok(errorHandlerIndex >= 0);
assert.strictEqual(source.lastIndexOf('app.use('), errorHandlerIndex);
});
test('抽取的纯函数不在 server.js 保留第二份定义', () => {
const extractedFunctions = [
'normalizeVersion',
'normalizeSha256',
'normalizeVirtualPath',
'buildHttpDownloadUrl',
'parseDownloadTrafficLine',
'isShareIpAllowed',
'resolveClientType',
'parseBooleanLike'
];
for (const functionName of extractedFunctions) {
assert.strictEqual(new RegExp(`function\\s+${functionName}\\s*\\(`).test(source), false, functionName);
}
});
test('旧占位路由已清除且真实系统路由已挂载', () => {
assert.strictEqual(fs.existsSync(path.join(projectRoot, 'backend', 'routes', 'health.js')), false);
assert.strictEqual(fs.existsSync(path.join(projectRoot, 'backend', 'routes', 'index.js')), false);
assert.ok(source.includes("require('./routes/system.routes')"));
assert.ok(source.includes("app.use('/api', createSystemRouter"));
});
test('package.json 与 package-lock.json 顶层依赖同步', () => {
const packageJson = require('../package.json');
const packageLock = require('../package-lock.json');
assert.deepStrictEqual(packageLock.packages[''].dependencies, packageJson.dependencies);
});
test('前端 package.json 与 lock 文件依赖同步且没有手工缓存版本号', () => {
const frontendPackage = require('../../frontend/package.json');
const frontendLock = require('../../frontend/package-lock.json');
assert.deepStrictEqual(frontendLock.packages[''].dependencies, frontendPackage.dependencies);
assert.deepStrictEqual(frontendLock.packages[''].devDependencies, frontendPackage.devDependencies);
const frontendDirectory = path.join(projectRoot, 'frontend');
for (const fileName of fs.readdirSync(frontendDirectory).filter((name) => name.endsWith('.html'))) {
const html = fs.readFileSync(path.join(frontendDirectory, fileName), 'utf8');
assert.strictEqual(/\?v=\d+/.test(html), false, fileName);
}
});
test('生产部署统一使用 Vite dist 且不会删除 lock 文件', () => {
const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8');
const nginxConfig = fs.readFileSync(path.join(projectRoot, 'nginx', 'nginx.conf'), 'utf8');
const nginxExample = fs.readFileSync(path.join(projectRoot, 'nginx', 'nginx.conf.example'), 'utf8');
assert.ok(installer.includes('npm ci --include=dev'));
assert.ok(installer.includes('npm run build'));
assert.ok(installer.includes('frontend/dist'));
assert.strictEqual(/rm\s+-rf\s+node_modules\s+package-lock\.json/.test(installer), false);
assert.ok(nginxConfig.includes('root /usr/share/nginx/html;'));
assert.ok(nginxConfig.includes('alias /runtime/downloads/;'));
for (const config of [installer, nginxConfig, nginxExample]) {
assert.ok(config.includes('proxy_hide_header X-Request-ID;'));
assert.ok(config.includes('proxy_hide_header Content-Security-Policy;'));
}
});
test('桌面安装包由 Gitea Release 发布且不会再次提交到 Git', () => {
const gitignore = fs.readFileSync(path.join(projectRoot, '.gitignore'), 'utf8');
const release = JSON.parse(fs.readFileSync(
path.join(projectRoot, 'frontend', 'desktop-release.json'),
'utf8'
));
const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8');
const compose = fs.readFileSync(path.join(projectRoot, 'docker-compose.yml'), 'utf8');
assert.ok(gitignore.split(/\r?\n/).includes('/frontend/downloads/*.exe'));
assert.strictEqual(gitignore.includes('!/frontend/downloads/'), false);
assert.match(release.sha256, /^[a-f0-9]{64}$/);
assert.ok(Number.isSafeInteger(release.size) && release.size > 0);
assert.strictEqual(release.tag, `v${release.version}`);
assert.strictEqual(release.publicPath, `/downloads/${release.asset}`);
assert.ok(installer.includes('sync_desktop_release'));
assert.ok(compose.includes('desktop-release:'));
});
test('Docker 构建上下文排除依赖、环境配置和运行时数据', () => {
const dockerIgnore = fs.readFileSync(path.join(projectRoot, 'backend', '.dockerignore'), 'utf8');
for (const requiredEntry of ['node_modules', '.env', 'data/*', 'storage/*']) {
assert.ok(dockerIgnore.split(/\r?\n/).includes(requiredEntry), requiredEntry);
}
const dockerfile = fs.readFileSync(path.join(projectRoot, 'backend', 'Dockerfile'), 'utf8');
assert.ok(dockerfile.includes('AS dependencies'));
assert.ok(dockerfile.includes('AS runtime'));
assert.ok(dockerfile.includes('npm_config_nodedir=/usr/local'));
});
test('install.sh 生成的 Nginx server 块花括号平衡', () => {
const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8');
const templates = [...installer.matchAll(/cat > .*?<< EOF\r?\n([\s\S]*?)\r?\nEOF/g)]
.map((match) => match[1])
.filter((template) => template.includes('server {'));
assert.strictEqual(templates.length, 3);
for (const template of templates) {
const openingBraces = (template.match(/\{/g) || []).length;
const closingBraces = (template.match(/\}/g) || []).length;
assert.strictEqual(openingBraces, closingBraces);
}
});
test('PM2 部署没有启用 cluster 或多实例参数', () => {
const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8');
assert.strictEqual(/pm2\s+start[^\n]*(?:\s-i\s|--instances)/.test(installer), false);
});
test('install.sh 可在最小化系统完成预检且不会吞掉 NodeSource 下载错误', () => {
const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8');
assert.ok(installer.includes("/dev/tcp/git.workyai.cn/443"));
assert.strictEqual(installer.includes('ping -c 1 git.workyai.cn'), false);
assert.strictEqual(/^\s*clear\s*$/m.test(installer), false);
assert.ok(installer.includes('clear_screen'));
assert.ok(installer.includes('main "$@"'));
assert.ok(installer.includes('/etc/apt/sources.list.d/ubuntu.sources'));
for (const dependency of ['ca-certificates', 'iproute2', 'dnsutils', 'procps']) {
assert.ok(installer.includes(dependency), dependency);
}
assert.ok(installer.includes('download_and_run_setup_script'));
assert.strictEqual(/curl[^\n]+\|\s*(?:ba)?sh/.test(installer), false);
assert.ok(installer.includes('verify_nodejs_installation'));
});
test('install.sh 生成可用且受保护的生产环境配置', () => {
const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8');
assert.ok(installer.includes('PUBLIC_BASE_URL=${PUBLIC_BASE_URL_VALUE}'));
assert.ok(installer.includes('ALLOWED_HOSTS=${ALLOWED_HOSTS_VALUE}'));
assert.ok(installer.includes('ALLOWED_ORIGINS=${ALLOWED_ORIGINS_VALUE}'));
assert.ok(installer.includes('chmod 600 "${PROJECT_DIR}/backend/.env"'));
assert.ok(installer.includes('dotenv_quote'));
assert.strictEqual(installer.includes('ALLOWED_ORIGINS_VALUE=""'), false);
assert.strictEqual(installer.includes('type_count++'), false);
assert.ok(installer.includes('/api/health'));
});
test('install.sh 只展示已实现的 SSL 方案且 Certbot 分支可达', () => {
const installer = fs.readFileSync(path.join(projectRoot, 'install.sh'), 'utf8');
assert.ok(installer.includes('deploy_certbot || ssl_fallback "1"'));
assert.ok(installer.includes('download_and_run_setup_script "https://get.acme.sh"'));
assert.strictEqual(installer.includes('证书申请功能开发中'), false);
assert.strictEqual(installer.includes('阿里云AccessKey ID'), false);
assert.strictEqual(installer.includes('腾讯云SecretId'), false);
});
console.log('\n========================================');
console.log('测试总结');
console.log('========================================');
console.log(`通过: ${results.passed}`);
console.log(`失败: ${results.failed}`);
process.exit(results.failed > 0 ? 1 : 0);
+31
View File
@@ -0,0 +1,31 @@
const assert = require('assert');
const { PassThrough } = require('stream');
const { finished } = require('stream/promises');
const { createZipArchive } = require('../utils/archive');
async function run() {
const archive = await createZipArchive({ store: true });
const output = new PassThrough();
const chunks = [];
output.on('data', (chunk) => chunks.push(chunk));
archive.pipe(output);
archive.append(Buffer.from('wanwanyun archive smoke test'), { name: 'smoke.txt' });
await archive.finalize();
await finished(output);
const zip = Buffer.concat(chunks);
assert.ok(zip.length > 30, 'ZIP output should not be empty');
assert.strictEqual(zip.subarray(0, 2).toString('ascii'), 'PK');
assert.ok(zip.includes(Buffer.from('smoke.txt')), 'ZIP should contain the requested entry');
console.log('通过: 1');
console.log('失败: 0');
}
run().catch((error) => {
console.error(error.stack || error.message);
console.log('通过: 0');
console.log('失败: 1');
process.exit(1);
});
+12 -130
View File
@@ -12,6 +12,12 @@
const assert = require('assert');
const path = require('path');
const fs = require('fs');
const {
sanitizeInput,
decodeHtmlEntities,
isSafePathSegment,
isFileExtensionSafe
} = require('../utils/input-security');
// 主函数包装器(支持 async/await
async function runTests() {
@@ -58,28 +64,6 @@ console.log('\n========== 1. 输入边界测试 ==========\n');
function testSanitizeInput() {
console.log('--- 测试 XSS 过滤函数 sanitizeInput ---');
// 从 server.js 复制的 sanitizeInput 函数
function sanitizeInput(str) {
if (typeof str !== 'string') return str;
let sanitized = str
.replace(/[&<>"']/g, (char) => {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;'
};
return map[char];
});
sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, '');
sanitized = sanitized.replace(/\x00/g, '');
return sanitized;
}
// 空字符串测试
test('空字符串输入应该返回空字符串', () => {
assert.strictEqual(sanitizeInput(''), '');
@@ -263,17 +247,6 @@ console.log('\n========== 2. 文件操作边界测试 ==========\n');
function testPathSecurity() {
console.log('--- 测试路径安全校验 ---');
function isSafePathSegment(name) {
return (
typeof name === 'string' &&
name.length > 0 &&
name.length <= 255 &&
!name.includes('..') &&
!/[/\\]/.test(name) &&
!/[\x00-\x1F]/.test(name)
);
}
test('空文件名应该被拒绝', () => {
assert.strictEqual(isSafePathSegment(''), false);
});
@@ -312,32 +285,6 @@ testPathSecurity();
function testFileExtensionSecurity() {
console.log('\n--- 测试文件扩展名安全 ---');
const DANGEROUS_EXTENSIONS = [
'.php', '.php3', '.php4', '.php5', '.phtml', '.phar',
'.jsp', '.jspx', '.jsw', '.jsv', '.jspf',
'.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx',
'.htaccess', '.htpasswd'
];
function isFileExtensionSafe(filename) {
if (!filename || typeof filename !== 'string') return false;
const ext = path.extname(filename).toLowerCase();
if (DANGEROUS_EXTENSIONS.includes(ext)) {
return false;
}
const nameLower = filename.toLowerCase();
for (const dangerExt of DANGEROUS_EXTENSIONS) {
if (nameLower.includes(dangerExt + '.')) {
return false;
}
}
return true;
}
test('PHP 文件应该被拒绝', () => {
assert.strictEqual(isFileExtensionSafe('test.php'), false);
assert.strictEqual(isFileExtensionSafe('shell.phtml'), false);
@@ -360,36 +307,8 @@ function testFileExtensionSecurity() {
});
test('.htaccess 和 .htpasswd 文件应该被拒绝', () => {
// 更新测试以匹配修复后的 isFileExtensionSafe 函数
// 现在会检查 dangerousFilenames 列表
const dangerousFilenames = ['.htaccess', '.htpasswd'];
function isFileExtensionSafeFixed(filename) {
if (!filename || typeof filename !== 'string') return false;
const ext = path.extname(filename).toLowerCase();
const nameLower = filename.toLowerCase();
if (DANGEROUS_EXTENSIONS.includes(ext)) {
return false;
}
// 特殊处理:检查以危险名称开头的文件
if (dangerousFilenames.includes(nameLower)) {
return false;
}
for (const dangerExt of DANGEROUS_EXTENSIONS) {
if (nameLower.includes(dangerExt + '.')) {
return false;
}
}
return true;
}
assert.strictEqual(isFileExtensionSafeFixed('.htaccess'), false);
assert.strictEqual(isFileExtensionSafeFixed('.htpasswd'), false);
assert.strictEqual(isFileExtensionSafe('.htaccess'), false);
assert.strictEqual(isFileExtensionSafe('.htpasswd'), false);
});
test('正常文件应该被接受', () => {
@@ -473,7 +392,7 @@ function testLocalStoragePath() {
return fullPath;
}
const basePath = '/tmp/storage/user_1';
const basePath = path.join(path.resolve('/tmp'), 'storage', 'user_1');
test('正常相对路径应该被接受', () => {
const result = getFullPath(basePath, 'documents/file.txt');
@@ -703,7 +622,7 @@ function testDatabaseFieldWhitelist() {
const ALLOWED_FIELDS = [
'username', 'email', 'password',
'oss_provider', 'oss_region', 'oss_access_key_id', 'oss_access_key_secret', 'oss_bucket', 'oss_endpoint',
'upload_api_key', 'is_admin', 'is_active', 'is_banned', 'has_oss_config',
'upload_api_key', 'is_active', 'is_banned', 'has_oss_config',
'is_verified', 'verification_token', 'verification_expires_at',
'storage_permission', 'current_storage_type', 'local_storage_quota', 'local_storage_used',
'theme_preference'
@@ -730,14 +649,14 @@ function testDatabaseFieldWhitelist() {
const updates = {
username: 'newname',
id: 999, // 尝试修改 ID
is_admin: 1, // 合法字段
is_admin: 1, // 权限字段不允许通过通用更新入口修改
sql_injection: "'; DROP TABLE users; --" // 非法字段
};
const filtered = filterUpdates(updates);
assert.ok(!('id' in filtered));
assert.ok(!('is_admin' in filtered));
assert.ok(!('sql_injection' in filtered));
assert.strictEqual(filtered.username, 'newname');
assert.strictEqual(filtered.is_admin, 1);
});
test('原型污染尝试应该被阻止', () => {
@@ -771,43 +690,6 @@ console.log('\n========== 7. HTML 实体解码测试 ==========\n');
function testHtmlEntityDecoding() {
console.log('--- 测试 HTML 实体解码 ---');
function decodeHtmlEntities(str) {
if (typeof str !== 'string') return str;
const entityMap = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
'#x27': "'",
'#x2F': '/',
'#x60': '`'
};
const decodeOnce = (input) =>
input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => {
if (code[0] === '#') {
const isHex = code[1]?.toLowerCase() === 'x';
const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
if (!Number.isNaN(num)) {
return String.fromCharCode(num);
}
return match;
}
const mapped = entityMap[code];
return mapped !== undefined ? mapped : match;
});
let output = str;
let decoded = decodeOnce(output);
while (decoded !== output) {
output = decoded;
decoded = decodeOnce(output);
}
return output;
}
test('基本 HTML 实体应该被解码', () => {
assert.strictEqual(decodeHtmlEntities('&lt;'), '<');
assert.strictEqual(decodeHtmlEntities('&gt;'), '>');
+155
View File
@@ -0,0 +1,155 @@
const assert = require('assert');
const { normalizeNonNegativeInteger, parseBooleanLike } = require('../utils/coerce');
const {
buildDeviceName,
detectDeviceTypeFromUserAgent,
inferPlatformFromUserAgent,
normalizeClientIp,
normalizeClientType,
resolveClientType,
sanitizeDeviceText
} = require('../utils/device');
const { parseDownloadTrafficLine, parseDownloadTrafficLogTime } = require('../utils/oss-log-parser');
const { isPathInside, normalizeVirtualPath } = require('../utils/path-safety');
const { isPathWithinShare, isShareIpAllowed, isValidShareCode, parseShareIpWhitelist } = require('../utils/share');
const { buildHttpDownloadUrl, sanitizeHttpBaseUrl } = require('../utils/url');
const {
compareLooseVersion,
normalizeReleaseNotes,
normalizeSha256,
normalizeVersion
} = require('../utils/version');
const results = { passed: 0, failed: 0 };
function test(name, fn) {
try {
fn();
results.passed += 1;
console.log(` [PASS] ${name}`);
} catch (error) {
results.failed += 1;
console.error(` [FAIL] ${name}: ${error.message}`);
}
}
console.log('\n========== 领域纯函数测试 ==========\n');
test('版本号规范化与宽松比较', () => {
assert.strictEqual(normalizeVersion(' v1.2.3 '), 'v1.2.3');
assert.strictEqual(normalizeVersion('', '1.0.0'), '1.0.0');
assert.strictEqual(compareLooseVersion('v1.10.0', '1.9.9'), 1);
assert.strictEqual(compareLooseVersion('1.2', '1.2.0'), 0);
assert.strictEqual(compareLooseVersion('1.2.0', '2.0.0'), -1);
});
test('发布说明转义换行被还原', () => {
assert.strictEqual(normalizeReleaseNotes('first\\r\\nsecond\\n'), 'first\nsecond');
});
test('SHA256 只接受完整十六进制摘要', () => {
assert.strictEqual(normalizeSha256(' A'.repeat(64)), '');
assert.strictEqual(normalizeSha256('A'.repeat(64)), 'a'.repeat(64));
assert.strictEqual(normalizeSha256('a'.repeat(63)), '');
});
test('非负整数与布尔配置规范化', () => {
assert.strictEqual(normalizeNonNegativeInteger(12.9), 12);
assert.strictEqual(normalizeNonNegativeInteger(-1, 4), 4);
assert.strictEqual(parseBooleanLike('YES'), true);
assert.strictEqual(parseBooleanLike('off', true), false);
assert.strictEqual(parseBooleanLike('invalid', true), true);
});
test('物理路径边界不接受兄弟目录', () => {
const parent = require('path').resolve('/tmp/root');
assert.strictEqual(isPathInside(parent, require('path').join(parent, 'child')), true);
assert.strictEqual(isPathInside(parent, require('path').resolve('/tmp/root-other')), false);
});
test('虚拟路径统一分隔符并拒绝遍历和空字节', () => {
assert.strictEqual(normalizeVirtualPath('folder\\file.txt'), '/folder/file.txt');
assert.strictEqual(normalizeVirtualPath('/folder/../secret'), null);
assert.strictEqual(normalizeVirtualPath('/folder/%2e%2e/secret'), null);
assert.strictEqual(normalizeVirtualPath('/folder/%2500.txt'), null);
assert.strictEqual(normalizeVirtualPath('/'), '/');
});
test('HTTP 基础地址拒绝危险协议并移除查询参数', () => {
assert.strictEqual(sanitizeHttpBaseUrl('javascript:alert(1)'), null);
assert.strictEqual(sanitizeHttpBaseUrl('https://example.test/base/?token=x#hash'), 'https://example.test/base');
});
test('下载地址逐段编码且不继承查询参数', () => {
assert.strictEqual(
buildHttpDownloadUrl('https://example.test/base?token=x', '/目录/a b.txt'),
'https://example.test/base/%E7%9B%AE%E5%BD%95/a%20b.txt'
);
});
test('OSS 日志时间正确应用时区', () => {
const date = parseDownloadTrafficLogTime('[27/Jul/2026:12:34:56 +0800]');
assert.strictEqual(date.toISOString(), '2026-07-27T04:34:56.000Z');
assert.strictEqual(parseDownloadTrafficLogTime('[31/Feb/2026:12:00:00 +0800]'), null);
assert.strictEqual(parseDownloadTrafficLogTime('[27/Jul/2026:12:00:00 +2460]'), null);
});
test('OSS 下载日志解析用户、对象键、字节和时间', () => {
const line = '1.2.3.4 - - [27/Jul/2026:12:34:56 +0800] "GET /user_42/folder/a%20b.txt?signature=x HTTP/1.1" 206 1024 "-" "ua"';
const result = parseDownloadTrafficLine(line);
assert.strictEqual(result.userId, 42);
assert.strictEqual(result.bytes, 1024);
assert.strictEqual(result.objectKey, 'user_42/folder/a b.txt');
assert.strictEqual(result.eventAt.toISOString(), '2026-07-27T04:34:56.000Z');
});
test('OSS 日志只统计成功 GET 下载', () => {
const fallback = new Date('2026-01-01T00:00:00Z');
assert.strictEqual(parseDownloadTrafficLine('"HEAD /user_1/a HTTP/1.1" 200 10', fallback), null);
assert.strictEqual(parseDownloadTrafficLine('"GET /user_1/a HTTP/1.1" 404 10', fallback), null);
assert.strictEqual(parseDownloadTrafficLine('"GET /public/a HTTP/1.1" 200 10', fallback), null);
assert.strictEqual(parseDownloadTrafficLine('"GET /user_1/a HTTP/1.1" 200 -', fallback), null);
});
test('分享码与分享路径严格限定', () => {
assert.strictEqual(isValidShareCode('Abc123'), true);
assert.strictEqual(isValidShareCode('../bad'), false);
assert.strictEqual(isPathWithinShare('/folder/a.txt', { share_type: 'folder', share_path: '/folder' }), true);
assert.strictEqual(isPathWithinShare('/folder-other/a.txt', { share_type: 'folder', share_path: '/folder' }), false);
assert.strictEqual(isPathWithinShare('/folder/a.txt', { share_type: 'file', share_path: '/folder/a.txt' }), true);
assert.strictEqual(isPathWithinShare('/folder/b.txt', { share_type: 'file', share_path: '/folder/a.txt' }), false);
});
test('IP 白名单支持分隔符、精确匹配与前缀规则', () => {
const whitelist = parseShareIpWhitelist('127.0.0.1, 10.0.*;192.168.1.5');
assert.deepStrictEqual(whitelist, ['127.0.0.1', '10.0.*', '192.168.1.5']);
assert.strictEqual(isShareIpAllowed('10.0.2.3', whitelist), true);
assert.strictEqual(isShareIpAllowed('192.168.1.6', whitelist), false);
assert.strictEqual(isShareIpAllowed('', whitelist), false);
assert.strictEqual(isShareIpAllowed('', []), true);
});
test('客户端 IP 与设备类型被稳定规范化', () => {
assert.strictEqual(normalizeClientIp('::ffff:127.0.0.1'), '127.0.0.1');
assert.strictEqual(normalizeClientIp('::1'), '127.0.0.1');
assert.strictEqual(detectDeviceTypeFromUserAgent('Mozilla/5.0 iPhone'), 'mobile');
assert.strictEqual(inferPlatformFromUserAgent('Mozilla/5.0 (Windows NT 10.0)'), 'Windows');
assert.strictEqual(normalizeClientType(' DESKTOP '), 'desktop');
assert.strictEqual(normalizeClientType('bot'), '');
assert.strictEqual(resolveClientType('', 'wanwan-cloud-desktop/1.0'), 'desktop');
});
test('设备名称限制长度并按客户端类型回退', () => {
assert.strictEqual(sanitizeDeviceText(' abc ', 2), 'ab');
assert.strictEqual(buildDeviceName({ clientType: 'mobile', platform: 'iOS' }), '移动端浏览器 · iOS');
assert.strictEqual(buildDeviceName({ clientType: 'web', deviceName: ' 我的设备 ', platform: 'Windows' }), '我的设备');
});
console.log('\n========================================');
console.log('测试总结');
console.log('========================================');
console.log(`通过: ${results.passed}`);
console.log(`失败: ${results.failed}`);
process.exit(results.failed > 0 ? 1 : 0);
+500
View File
@@ -0,0 +1,500 @@
/**
* Full project audit regression harness.
*
* Starts the backend with an isolated database/storage root and exercises the
* highest-risk public HTTP flows through real routes, cookies and CSRF.
*/
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const BACKEND_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(BACKEND_DIR, 'server.js');
const AUDIT_PREFIX = `audit_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
function randomHex(bytes = 32) {
return crypto.randomBytes(bytes).toString('hex');
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function countRegularFiles(dir) {
if (!fs.existsSync(dir)) return 0;
let count = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isFile()) count += 1;
}
return count;
}
class CookieJar {
constructor() {
this.cookies = new Map();
}
store(setCookie) {
const list = Array.isArray(setCookie) ? setCookie : (setCookie ? [setCookie] : []);
for (const raw of list) {
const first = String(raw).split(';')[0];
const idx = first.indexOf('=');
if (idx <= 0) continue;
this.cookies.set(first.slice(0, idx), first.slice(idx + 1));
}
}
header() {
return Array.from(this.cookies.entries())
.map(([key, value]) => `${key}=${value}`)
.join('; ');
}
get(name) {
return this.cookies.get(name) || '';
}
}
async function request(baseUrl, jar, method, route, options = {}) {
const url = new URL(route, baseUrl);
const headers = { ...(options.headers || {}) };
const cookieHeader = jar?.header();
if (cookieHeader) headers.Cookie = cookieHeader;
const upperMethod = method.toUpperCase();
if (!['GET', 'HEAD', 'OPTIONS'].includes(upperMethod) && options.csrf !== false) {
const csrf = jar?.get('csrf_token');
if (csrf) headers['X-CSRF-Token'] = csrf;
}
let body = options.body;
if (options.json !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(options.json);
}
const response = await fetch(url, {
method: upperMethod,
headers,
body,
redirect: options.redirect || 'manual'
});
jar?.store(response.headers.getSetCookie ? response.headers.getSetCookie() : response.headers.get('set-cookie'));
const contentType = response.headers.get('content-type') || '';
const buffer = Buffer.from(await response.arrayBuffer());
let data = buffer;
if (contentType.includes('application/json')) {
data = JSON.parse(buffer.toString('utf8') || '{}');
} else if (contentType.includes('text/') || contentType.includes('image/svg')) {
data = buffer.toString('utf8');
}
return {
status: response.status,
headers: response.headers,
data,
raw: buffer
};
}
async function waitForHealth(baseUrl, timeoutMs = 15000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const res = await request(baseUrl, null, 'GET', '/api/health');
if (res.status === 200 && res.data?.success === true) return;
} catch {}
await delay(250);
}
throw new Error('server did not become healthy');
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = http.createServer();
server.listen(0, '127.0.0.1', () => {
const address = server.address();
server.close(() => resolve(address.port));
});
server.on('error', reject);
});
}
async function run() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wanwanyun-full-audit-'));
const storageRoot = path.join(tempRoot, 'storage');
const dbPath = path.join(tempRoot, 'database.db');
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const uploadsDir = path.join(BACKEND_DIR, 'uploads');
const adminPassword = `${AUDIT_PREFIX}_Pass123!`;
const env = {
...process.env,
PORT: String(port),
NODE_ENV: 'development',
DATABASE_PATH: dbPath,
STORAGE_ROOT: storageRoot,
JWT_SECRET: randomHex(32),
ENCRYPTION_KEY: randomHex(32),
ADMIN_USERNAME: 'admin',
ADMIN_PASSWORD: adminPassword,
PUBLIC_BASE_URL: baseUrl,
ALLOWED_ORIGINS: baseUrl,
COOKIE_SECURE: 'false',
ENABLE_CSRF: 'true',
ENFORCE_HTTPS: 'false',
TRUST_PROXY: 'false',
WAL_CHECKPOINT_ENABLED: 'false'
};
const child = spawn(process.execPath, [SERVER_PATH], {
cwd: BACKEND_DIR,
env,
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => {
stdout += chunk.toString();
});
child.stderr.on('data', chunk => {
stderr += chunk.toString();
});
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
try {
await waitForHealth(baseUrl);
const jar = new CookieJar();
let userId = 1;
let shareId = null;
let shareCode = '';
let directLinkId = null;
let directLinkCode = '';
let artifactDirName = AUDIT_PREFIX;
test('public health/config/csrf endpoints are reachable', async () => {
const health = await request(baseUrl, jar, 'GET', '/api/health');
assert.strictEqual(health.status, 200);
assert.strictEqual(health.data.success, true);
const config = await request(baseUrl, jar, 'GET', '/api/config');
assert.strictEqual(config.status, 200);
assert.strictEqual(config.data.success, true);
const csrf = await request(baseUrl, jar, 'GET', '/api/csrf-token');
assert.strictEqual(csrf.status, 200);
assert.ok(csrf.data.csrfToken);
assert.ok(jar.get('csrf_token'));
});
test('malformed JSON receives the global JSON error response', async () => {
const malformed = await request(baseUrl, new CookieJar(), 'POST', '/api/login', {
csrf: false,
headers: { 'Content-Type': 'application/json' },
body: '{"invalid":}'
});
assert.strictEqual(malformed.status, 400);
assert.strictEqual(malformed.data.success, false);
assert.strictEqual(typeof malformed.data.message, 'string');
});
test('auth endpoints login with real cookies and enforce CSRF after authentication', async () => {
const login = await request(baseUrl, jar, 'POST', '/api/login', {
json: { username: 'admin', password: adminPassword }
});
assert.strictEqual(login.status, 200);
assert.strictEqual(login.data.success, true);
assert.ok(jar.get('token'));
assert.ok(jar.get('refreshToken'));
userId = login.data.user.id;
const profile = await request(baseUrl, jar, 'GET', '/api/user/profile');
assert.strictEqual(profile.status, 200);
assert.strictEqual(profile.data.success, true);
assert.strictEqual(profile.data.user.id, userId);
assert.strictEqual(profile.data.user.oss_access_key_secret, undefined);
const csrfBlocked = await request(baseUrl, jar, 'POST', '/api/user/theme', {
csrf: false,
json: { theme: 'light' }
});
assert.strictEqual(csrfBlocked.status, 403);
});
test('admin can move isolated audit user to local storage only', async () => {
const res = await request(baseUrl, jar, 'POST', `/api/admin/users/${userId}/storage-permission`, {
json: {
storage_permission: 'local_only',
local_storage_quota: 10 * 1024 * 1024,
download_traffic_quota: -1,
reset_download_traffic_used: true
}
});
assert.strictEqual(res.status, 200);
assert.strictEqual(res.data.success, true);
assert.strictEqual(res.data.user.current_storage_type, 'local');
});
test('file manager rejects unsafe folder names and handles normal local file flow', async () => {
const badMkdir = await request(baseUrl, jar, 'POST', '/api/files/mkdir', {
json: { path: '/', folderName: '../bad' }
});
assert.strictEqual(badMkdir.status, 400);
const badList = await request(baseUrl, jar, 'GET', '/api/files?path=/../secret');
assert.strictEqual(badList.status, 400);
const mkdir = await request(baseUrl, jar, 'POST', '/api/files/mkdir', {
json: { path: '/', folderName: AUDIT_PREFIX }
});
assert.strictEqual(mkdir.status, 200);
assert.strictEqual(mkdir.data.success, true);
const file = new Blob([`hello ${AUDIT_PREFIX}`], { type: 'text/plain' });
const form = new FormData();
form.append('path', `/${AUDIT_PREFIX}`);
form.append('file', file, 'hello.txt');
const upload = await request(baseUrl, jar, 'POST', '/api/upload', { body: form });
assert.strictEqual(upload.status, 200);
assert.strictEqual(upload.data.success, true);
assert.strictEqual(upload.data.path, `/${AUDIT_PREFIX}/hello.txt`);
const list = await request(baseUrl, jar, 'GET', `/api/files?path=/${encodeURIComponent(AUDIT_PREFIX)}`);
assert.strictEqual(list.status, 200);
assert.ok(list.data.items.some(item => item.name === 'hello.txt'));
const search = await request(baseUrl, jar, 'GET', `/api/files/search?keyword=hello&path=/${encodeURIComponent(AUDIT_PREFIX)}`);
assert.strictEqual(search.status, 200);
assert.ok(search.data.items.some(item => item.name === 'hello.txt'));
});
test('failed normal upload validation must not leave multer temp files', async () => {
fs.mkdirSync(uploadsDir, { recursive: true });
const before = countRegularFiles(uploadsDir);
const form = new FormData();
form.append('path', '/../blocked');
form.append('file', new Blob(['leak candidate'], { type: 'text/plain' }), 'leak.txt');
const res = await request(baseUrl, jar, 'POST', '/api/upload', { body: form });
assert.strictEqual(res.status, 400);
const after = countRegularFiles(uploadsDir);
assert.strictEqual(after, before, `uploads temp leak: before=${before}, after=${after}`);
});
test('download URL/check/download work for local file and reject traversal', async () => {
const traversal = await request(baseUrl, jar, 'GET', '/api/files/download-check?path=/../secret.txt');
assert.strictEqual(traversal.status, 400);
const check = await request(baseUrl, jar, 'GET', `/api/files/download-check?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt`);
assert.strictEqual(check.status, 200);
assert.strictEqual(check.data.success, true);
const url = await request(baseUrl, jar, 'GET', `/api/files/download-url?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt&mode=download`);
assert.strictEqual(url.status, 200);
assert.strictEqual(url.data.storageType, 'local');
assert.match(url.data.downloadUrl, /\/api\/files\/download/);
const tokenDownload = await request(baseUrl, null, 'GET', url.data.downloadUrl);
assert.strictEqual(tokenDownload.status, 200);
assert.ok(tokenDownload.raw.toString('utf8').includes(AUDIT_PREFIX));
const legacyDownload = await request(baseUrl, jar, 'GET', `/api/files/download?path=/${encodeURIComponent(AUDIT_PREFIX)}/hello.txt`);
assert.strictEqual(legacyDownload.status, 302);
const legacyLocation = legacyDownload.headers.get('location') || '';
assert.match(legacyLocation, /\/api\/files\/download\?.*token=/);
const download = await request(baseUrl, null, 'GET', legacyLocation);
assert.strictEqual(download.status, 200);
assert.ok(download.raw.toString('utf8').includes(AUDIT_PREFIX));
const partialUrl = new URL(url.data.downloadUrl);
const partial = await request(baseUrl, null, 'GET', partialUrl.toString(), {
headers: { Range: 'bytes=6-' }
});
assert.strictEqual(partial.status, 206);
assert.match(partial.headers.get('content-range') || '', /^bytes 6-/);
assert.strictEqual(partial.raw.toString('utf8'), download.raw.subarray(6).toString('utf8'));
});
test('share and direct-link flows preserve path boundaries', async () => {
const createShare = await request(baseUrl, jar, 'POST', '/api/share/create', {
json: {
share_type: 'directory',
file_path: `/${AUDIT_PREFIX}`,
file_name: AUDIT_PREFIX,
password: `${AUDIT_PREFIX}_pw`,
expiry_days: 1,
max_downloads: 5,
device_limit: 'all'
}
});
assert.strictEqual(createShare.status, 200);
assert.strictEqual(createShare.data.success, true);
shareId = createShare.data.share_id;
shareCode = createShare.data.share_code;
const badVerify = await request(baseUrl, new CookieJar(), 'POST', `/api/share/${shareCode}/verify`, {
json: { password: 'wrong' }
});
assert.strictEqual(badVerify.status, 401);
const publicJar = new CookieJar();
const verify = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/verify`, {
json: { password: `${AUDIT_PREFIX}_pw` }
});
assert.strictEqual(verify.status, 200);
assert.strictEqual(verify.data.success, true);
const list = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/list`, {
json: { path: '', password: `${AUDIT_PREFIX}_pw` }
});
assert.strictEqual(list.status, 200);
assert.strictEqual(list.data.success, true);
assert.ok(list.data.items.some(item => item.name === 'hello.txt'));
const traversal = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/download-url`, {
json: { path: '/../database.db', mode: 'download', password: `${AUDIT_PREFIX}_pw` }
});
assert.ok([400, 403, 404].includes(traversal.status));
const shareUrl = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/download-url`, {
json: {
path: `/${AUDIT_PREFIX}/hello.txt`,
mode: 'download',
password: `${AUDIT_PREFIX}_pw`
}
});
assert.strictEqual(shareUrl.status, 200);
assert.match(shareUrl.data.downloadUrl, /\/api\/share\/[^/]+\/download-file\?.*token=/);
const shareDownload = await request(baseUrl, null, 'GET', shareUrl.data.downloadUrl);
assert.strictEqual(shareDownload.status, 200);
assert.ok(shareDownload.raw.toString('utf8').includes(AUDIT_PREFIX));
const sharePartial = await request(baseUrl, null, 'GET', shareUrl.data.downloadUrl, {
headers: { Range: 'bytes=6-' }
});
assert.strictEqual(sharePartial.status, 206);
assert.strictEqual(
sharePartial.raw.toString('utf8'),
shareDownload.raw.subarray(6).toString('utf8')
);
const direct = await request(baseUrl, jar, 'POST', '/api/direct-link/create', {
json: {
file_path: `/${AUDIT_PREFIX}/hello.txt`,
file_name: 'hello.txt',
expiry_days: 1
}
});
assert.strictEqual(direct.status, 200);
assert.strictEqual(direct.data.success, true);
directLinkId = direct.data.link_id;
directLinkCode = direct.data.link_code;
});
test('renaming a directory preserves shares and direct links', async () => {
const renamedDir = `${AUDIT_PREFIX}_renamed`;
const rename = await request(baseUrl, jar, 'POST', '/api/files/rename', {
json: { path: '/', oldName: AUDIT_PREFIX, newName: renamedDir }
});
assert.strictEqual(rename.status, 200);
assert.strictEqual(rename.data.success, true);
artifactDirName = renamedDir;
const shares = await request(baseUrl, jar, 'GET', '/api/share/my');
const share = shares.data.shares.find(item => item.share_code === shareCode);
assert.strictEqual(share.share_path, `/${renamedDir}`);
const publicJar = new CookieJar();
const list = await request(baseUrl, publicJar, 'POST', `/api/share/${shareCode}/list`, {
json: { path: '', password: `${AUDIT_PREFIX}_pw` }
});
assert.strictEqual(list.status, 200);
assert.ok(list.data.items.some(item => item.name === 'hello.txt'));
const directDownload = await request(baseUrl, null, 'GET', `/d/${directLinkCode}`);
assert.strictEqual(directDownload.status, 200);
assert.ok(directDownload.raw.toString('utf8').includes(AUDIT_PREFIX));
});
test('admin listing/logging endpoints are authenticated and sanitized', async () => {
const noAuth = await request(baseUrl, new CookieJar(), 'GET', '/api/admin/users');
assert.strictEqual(noAuth.status, 401);
const users = await request(baseUrl, jar, 'GET', '/api/admin/users?page=1&pageSize=10');
assert.strictEqual(users.status, 200);
assert.strictEqual(users.data.success, true);
const adminRow = users.data.users.find(user => user.id === userId);
assert.ok(adminRow);
assert.strictEqual(adminRow.password, undefined);
assert.strictEqual(adminRow.oss_access_key_secret, undefined);
const logs = await request(baseUrl, jar, 'GET', '/api/admin/logs?page=1&pageSize=5');
assert.strictEqual(logs.status, 200);
assert.strictEqual(logs.data.success, true);
});
test('cleanup via public APIs succeeds for audit artifacts', async () => {
if (directLinkId) {
const res = await request(baseUrl, jar, 'DELETE', `/api/direct-link/${directLinkId}`);
assert.ok([200, 404].includes(res.status));
}
if (shareId) {
const res = await request(baseUrl, jar, 'DELETE', `/api/share/${shareId}`);
assert.ok([200, 404].includes(res.status));
}
const del = await request(baseUrl, jar, 'POST', '/api/files/delete', {
json: { path: '/', fileName: artifactDirName }
});
assert.strictEqual(del.status, 200);
assert.strictEqual(del.data.success, true);
});
const failures = [];
for (const item of tests) {
try {
await item.fn();
console.log(`[PASS] ${item.name}`);
} catch (error) {
failures.push({ name: item.name, error });
console.error(`[FAIL] ${item.name}`);
console.error(error.stack || error.message);
}
}
if (failures.length > 0) {
const summary = failures.map(item => `- ${item.name}: ${item.error.message}`).join('\n');
throw new Error(`full audit regression failed:\n${summary}`);
}
console.log(`PASS full-audit-regression (${tests.length} tests)`);
} catch (error) {
console.error('--- backend stdout tail ---');
console.error(stdout.split(/\r?\n/).slice(-80).join('\n'));
console.error('--- backend stderr tail ---');
console.error(stderr.split(/\r?\n/).slice(-80).join('\n'));
throw error;
} finally {
child.kill('SIGTERM');
await delay(500);
if (!child.killed) child.kill('SIGKILL');
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
run().catch(error => {
console.error(error.stack || error.message);
process.exit(1);
});
+180
View File
@@ -0,0 +1,180 @@
process.env.LOG_LEVEL = 'silent';
const assert = require('assert');
const { EventEmitter } = require('events');
const fs = require('fs');
const {
createCsrfCookieMiddleware,
csrfProtection,
generateCsrfToken,
tokensEqual
} = require('../middleware/csrf');
const { RateLimiter } = require('../middleware/rate-limit');
const { applySecurityHeaders, createHttpsEnforcementMiddleware } = require('../middleware/security');
const { expressErrorHandler } = require('../middleware/error-handler');
const { requestContextMiddleware, redactLogValue } = require('../utils/logger');
const { createHealthService, normalizeMinimumFreeBytes } = require('../services/health-service');
const results = { passed: 0, failed: 0 };
function test(name, fn) {
try {
fn();
results.passed += 1;
console.log(` [PASS] ${name}`);
} catch (error) {
results.failed += 1;
console.error(` [FAIL] ${name}: ${error.message}`);
}
}
function createResponse() {
const response = new EventEmitter();
response.headers = {};
response.statusCode = 200;
response.payload = null;
response.cookies = [];
response.setHeader = (name, value) => { response.headers[name] = value; };
response.removeHeader = (name) => { delete response.headers[name]; };
response.status = (code) => { response.statusCode = code; return response; };
response.json = (payload) => { response.payload = payload; return response; };
response.cookie = (name, value, options) => { response.cookies.push({ name, value, options }); };
return response;
}
console.log('\n========== 中间件与可观测性测试 ==========\n');
test('安全响应头覆盖 CSP、HSTS、Referrer 与 Permissions Policy', () => {
const response = createResponse();
applySecurityHeaders({ secure: true }, response);
assert.ok(response.headers['Content-Security-Policy'].includes("object-src 'none'"));
assert.ok(response.headers['Strict-Transport-Security']);
assert.strictEqual(response.headers['X-Content-Type-Options'], 'nosniff');
assert.strictEqual(response.headers['Referrer-Policy'], 'strict-origin-when-cross-origin');
assert.ok(response.headers['Permissions-Policy']);
});
test('HTTPS 中间件阻止不安全请求但放行本机健康检查', () => {
const middleware = createHttpsEnforcementMiddleware({ enabled: true });
const blockedResponse = createResponse();
middleware({ path: '/api/config', secure: false, socket: { remoteAddress: '10.0.0.1' }, id: 'req-1' }, blockedResponse, () => assert.fail('不应放行'));
assert.strictEqual(blockedResponse.statusCode, 400);
assert.strictEqual(blockedResponse.payload.requestId, 'req-1');
let nextCalled = false;
middleware({ path: '/api/health', secure: false, socket: { remoteAddress: '127.0.0.1' } }, createResponse(), () => { nextCalled = true; });
assert.strictEqual(nextCalled, true);
});
test('请求上下文接受安全 requestId 并回写响应头', () => {
const request = { headers: { 'x-request-id': 'trace-123' }, method: 'GET', path: '/api/config' };
const response = createResponse();
requestContextMiddleware(request, response, () => {});
assert.strictEqual(request.id, 'trace-123');
assert.strictEqual(response.headers['X-Request-ID'], 'trace-123');
response.emit('finish');
});
test('非法 requestId 被替换,日志敏感字段被脱敏', () => {
const request = { headers: { 'x-request-id': 'bad id\n' }, method: 'GET', path: '/' };
const response = createResponse();
requestContextMiddleware(request, response, () => {});
assert.notStrictEqual(request.id, 'bad id\n');
assert.match(request.id, /^[0-9a-f-]{36}$/);
const redacted = redactLogValue({ password: 'secret', note: 'token=abc', nested: { authorization: 'Bearer value' } });
assert.strictEqual(redacted.password, '[REDACTED]');
assert.strictEqual(redacted.note, 'token=[REDACTED]');
assert.strictEqual(redacted.nested.authorization, '[REDACTED]');
response.emit('finish');
});
test('CSRF token 使用固定长度并支持常量时间比较', () => {
const token = generateCsrfToken();
assert.match(token, /^[a-f0-9]{64}$/);
assert.strictEqual(tokensEqual(token, token), true);
assert.strictEqual(tokensEqual(token, `${token}0`), false);
});
test('CSRF Cookie 中间件只在缺少 token 时设置 Cookie', () => {
const middleware = createCsrfCookieMiddleware({ secureCookies: true });
const response = createResponse();
middleware({ cookies: {} }, response, () => {});
assert.strictEqual(response.cookies.length, 1);
assert.strictEqual(response.cookies[0].options.secure, true);
assert.strictEqual(response.cookies[0].options.sameSite, 'strict');
});
test('Cookie 会话的写请求必须通过 CSRF 校验', () => {
const response = createResponse();
csrfProtection({
method: 'POST',
path: '/api/example',
id: 'req-2',
cookies: { token: 'session', csrf_token: 'a' },
headers: { 'x-csrf-token': 'b' }
}, response, () => assert.fail('不应放行'));
assert.strictEqual(response.statusCode, 403);
assert.strictEqual(response.payload.requestId, 'req-2');
});
test('失败限流器达到阈值后封锁并可在成功后清除', () => {
const limiter = new RateLimiter({ maxAttempts: 2, windowMs: 1000, blockDuration: 1000 });
try {
assert.strictEqual(limiter.recordFailure('key').blocked, false);
assert.strictEqual(limiter.recordFailure('key').blocked, true);
assert.strictEqual(limiter.isBlocked('key'), true);
limiter.recordSuccess('key');
assert.strictEqual(limiter.isBlocked('key'), false);
} finally {
limiter.destroy();
}
});
test('健康检查同时验证数据库与磁盘', () => {
const service = createHealthService({
db: { prepare: () => ({ get: () => ({ ok: 1 }) }) },
diskPath: __dirname,
minimumFreeBytes: 0
});
const result = service.check();
assert.strictEqual(result.healthy, true);
assert.strictEqual(result.checks.database.status, 'ok');
assert.strictEqual(result.checks.disk.status, 'ok');
assert.ok(result.checks.disk.freeBytes > 0);
assert.strictEqual(fs.existsSync(__dirname), true);
assert.strictEqual(normalizeMinimumFreeBytes(-1), 256 * 1024 * 1024);
});
test('数据库异常会让健康状态降级', () => {
const service = createHealthService({
db: { prepare: () => { throw Object.assign(new Error('down'), { code: 'SQLITE_DOWN' }); } },
diskPath: __dirname,
minimumFreeBytes: 0
});
const result = service.check();
assert.strictEqual(result.healthy, false);
assert.strictEqual(result.checks.database.status, 'error');
});
test('错误响应携带 requestId 并隐藏服务端异常', () => {
const response = createResponse();
expressErrorHandler(new Error('secret detail'), {
id: 'req-3',
method: 'GET',
path: '/api/example'
}, response, () => assert.fail('不应继续'));
assert.deepStrictEqual(response.payload, {
success: false,
message: '服务器内部错误',
requestId: 'req-3'
});
});
console.log('\n========================================');
console.log('测试总结');
console.log('========================================');
console.log(`通过: ${results.passed}`);
console.log(`失败: ${results.failed}`);
process.exit(results.failed > 0 ? 1 : 0);
+246
View File
@@ -0,0 +1,246 @@
process.env.LOG_LEVEL = 'silent';
const assert = require('assert');
const {
parseDateTimeValue,
formatDateTimeForSqlite,
getDateKeyFromDate,
getRecentDateKeys,
getNextDownloadResetTime,
normalizeTimeHHmm,
isCurrentTimeInWindow
} = require('../utils/datetime');
const {
MAX_DOWNLOAD_TRAFFIC_BYTES,
normalizeDownloadTrafficQuota,
normalizeDownloadTrafficUsed,
getDownloadTrafficState,
resolveDownloadTrafficPolicyUpdates
} = require('../utils/download-quota');
const { expressErrorHandler } = require('../middleware/error-handler');
const results = { passed: 0, failed: 0 };
function test(name, fn) {
try {
fn();
results.passed += 1;
console.log(` [PASS] ${name}`);
} catch (error) {
results.failed += 1;
console.error(` [FAIL] ${name}: ${error.message}`);
}
}
function createResponse(headersSent = false) {
return {
headersSent,
statusCode: null,
payload: null,
status(code) {
this.statusCode = code;
return this;
},
json(payload) {
this.payload = payload;
return this;
}
};
}
console.log('\n========== 生产工具模块测试 ==========\n');
test('SQLite 日期时间可以被解析', () => {
const parsed = parseDateTimeValue('2026-07-27 12:34:56');
assert.ok(parsed instanceof Date);
assert.strictEqual(parsed.getFullYear(), 2026);
assert.strictEqual(parsed.getMonth(), 6);
assert.strictEqual(parsed.getDate(), 27);
});
test('非法日期时间返回 null', () => {
assert.strictEqual(parseDateTimeValue('not-a-date'), null);
assert.strictEqual(parseDateTimeValue(null), null);
});
test('日期时间按 SQLite 格式输出', () => {
const value = new Date(2026, 6, 27, 3, 4, 5);
assert.strictEqual(formatDateTimeForSqlite(value), '2026-07-27 03:04:05');
});
test('日期键校验有效和无效日期', () => {
assert.strictEqual(getDateKeyFromDate(new Date(2026, 0, 2)), '2026-01-02');
assert.strictEqual(getDateKeyFromDate('invalid'), null);
});
test('最近日期键按时间顺序生成', () => {
const keys = getRecentDateKeys(3, new Date(2026, 0, 2, 12, 0, 0));
assert.deepStrictEqual(keys, ['2025-12-31', '2026-01-01', '2026-01-02']);
});
test('下载配额重置时间支持日周月周期', () => {
assert.strictEqual(
formatDateTimeForSqlite(getNextDownloadResetTime('2026-01-01 00:00:00', 'daily')),
'2026-01-02 00:00:00'
);
assert.strictEqual(
formatDateTimeForSqlite(getNextDownloadResetTime('2026-01-01 00:00:00', 'weekly')),
'2026-01-08 00:00:00'
);
assert.strictEqual(
formatDateTimeForSqlite(getNextDownloadResetTime('2026-01-01 00:00:00', 'monthly')),
'2026-02-01 00:00:00'
);
assert.strictEqual(getNextDownloadResetTime('2026-01-01 00:00:00', 'none'), null);
});
test('访问时间格式被严格规范化', () => {
assert.strictEqual(normalizeTimeHHmm(' 09:05 '), '09:05');
assert.strictEqual(normalizeTimeHHmm('9:05'), null);
assert.strictEqual(normalizeTimeHHmm('24:00'), null);
});
test('同日访问时间窗口正确判断', () => {
assert.strictEqual(isCurrentTimeInWindow('09:00', '18:00', new Date(2026, 0, 1, 12, 0)), true);
assert.strictEqual(isCurrentTimeInWindow('09:00', '18:00', new Date(2026, 0, 1, 18, 0)), false);
});
test('跨日访问时间窗口正确判断', () => {
assert.strictEqual(isCurrentTimeInWindow('22:00', '06:00', new Date(2026, 0, 1, 23, 0)), true);
assert.strictEqual(isCurrentTimeInWindow('22:00', '06:00', new Date(2026, 0, 1, 12, 0)), false);
});
test('非法下载配额被禁止而负数统一为不限', () => {
assert.strictEqual(normalizeDownloadTrafficQuota('invalid'), 0);
assert.strictEqual(normalizeDownloadTrafficQuota(-99), -1);
});
test('下载配额被取整并限制在 10TB', () => {
assert.strictEqual(normalizeDownloadTrafficQuota(10.9), 10);
assert.strictEqual(normalizeDownloadTrafficQuota(MAX_DOWNLOAD_TRAFFIC_BYTES + 1), MAX_DOWNLOAD_TRAFFIC_BYTES);
});
test('已用流量按有限配额封顶', () => {
assert.strictEqual(normalizeDownloadTrafficUsed(150, 100), 100);
assert.strictEqual(normalizeDownloadTrafficUsed(150, -1), 150);
assert.strictEqual(normalizeDownloadTrafficUsed(-1, 100), 0);
});
test('不限流量状态保留无限剩余额度', () => {
const state = getDownloadTrafficState({ download_traffic_quota: -1, download_traffic_used: 123 });
assert.strictEqual(state.isUnlimited, true);
assert.strictEqual(state.used, 123);
assert.strictEqual(state.remaining, Number.POSITIVE_INFINITY);
});
test('空用户不会产生配额更新', () => {
assert.deepStrictEqual(resolveDownloadTrafficPolicyUpdates(null), {
updates: {},
hasUpdates: false,
expired: false,
resetApplied: false
});
});
test('非法重置周期会被规范化', () => {
const result = resolveDownloadTrafficPolicyUpdates({
download_traffic_quota: 100,
download_traffic_used: 0,
download_traffic_reset_cycle: 'yearly'
});
assert.strictEqual(result.updates.download_traffic_reset_cycle, 'none');
});
test('周期配额首次运行时记录重置时间', () => {
const now = new Date(2026, 6, 27, 12, 0, 0);
const result = resolveDownloadTrafficPolicyUpdates({
download_traffic_quota: 100,
download_traffic_used: 10,
download_traffic_reset_cycle: 'daily',
download_traffic_last_reset_at: null
}, now);
assert.strictEqual(result.updates.download_traffic_last_reset_at, '2026-07-27 12:00:00');
});
test('到达周期时已用流量归零', () => {
const result = resolveDownloadTrafficPolicyUpdates({
download_traffic_quota: 100,
download_traffic_used: 90,
download_traffic_reset_cycle: 'daily',
download_traffic_last_reset_at: '2026-07-26 12:00:00'
}, new Date(2026, 6, 27, 12, 0, 0));
assert.strictEqual(result.resetApplied, true);
assert.strictEqual(result.updates.download_traffic_used, 0);
});
test('限时配额到期后恢复为不限流量', () => {
const result = resolveDownloadTrafficPolicyUpdates({
download_traffic_quota: 100,
download_traffic_used: 90,
download_traffic_quota_expires_at: '2026-07-26 12:00:00',
download_traffic_reset_cycle: 'daily',
download_traffic_last_reset_at: '2026-07-26 12:00:00'
}, new Date(2026, 6, 27, 12, 0, 0));
assert.strictEqual(result.expired, true);
assert.strictEqual(result.updates.download_traffic_quota, -1);
assert.strictEqual(result.updates.download_traffic_used, 0);
assert.strictEqual(result.updates.download_traffic_quota_expires_at, null);
});
test('Express 错误处理器保留四参数签名', () => {
assert.strictEqual(expressErrorHandler.length, 4);
});
test('Express 错误处理器返回客户端错误 JSON', () => {
const response = createResponse();
const originalConsoleError = console.error;
console.error = () => {};
try {
expressErrorHandler(
Object.assign(new Error('请求 JSON 无效'), { status: 400 }),
{ method: 'POST', originalUrl: '/api/login' },
response,
() => assert.fail('不应调用 next')
);
} finally {
console.error = originalConsoleError;
}
assert.strictEqual(response.statusCode, 400);
assert.deepStrictEqual(response.payload, { success: false, message: '请求 JSON 无效' });
});
test('Express 错误处理器隐藏服务端异常详情', () => {
const response = createResponse();
const originalConsoleError = console.error;
console.error = () => {};
try {
expressErrorHandler(
new Error('sensitive detail'),
{ method: 'GET', originalUrl: '/api/example' },
response,
() => assert.fail('不应调用 next')
);
} finally {
console.error = originalConsoleError;
}
assert.strictEqual(response.statusCode, 500);
assert.deepStrictEqual(response.payload, { success: false, message: '服务器内部错误' });
});
test('响应头已发送时错误处理器继续交给 Express', () => {
const response = createResponse(true);
const error = new Error('stream failed');
let forwarded = null;
expressErrorHandler(error, {}, response, (received) => {
forwarded = received;
});
assert.strictEqual(forwarded, error);
});
console.log('\n========================================');
console.log('测试总结');
console.log('========================================');
console.log(`通过: ${results.passed}`);
console.log(`失败: ${results.failed}`);
process.exit(results.failed > 0 ? 1 : 0);
+73
View File
@@ -0,0 +1,73 @@
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const projectRoot = path.resolve(__dirname, '..', '..');
const syncScript = path.join(projectRoot, 'frontend', 'scripts', 'sync-desktop-release.js');
const results = { passed: 0, failed: 0 };
function test(name, fn) {
try {
fn();
results.passed += 1;
console.log(` [PASS] ${name}`);
} catch (error) {
results.failed += 1;
console.error(` [FAIL] ${name}: ${error.message}`);
}
}
console.log('\n========== 桌面安装包发布同步测试 ==========\n');
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wanwanyun-release-sync-'));
try {
const destinationDirectory = path.join(tempRoot, 'downloads');
const asset = 'wanwan-cloud-desktop_v9.9.9_x64-setup.exe';
const assetContent = Buffer.from('verified desktop release fixture');
const sha256 = crypto.createHash('sha256').update(assetContent).digest('hex');
const metadataPath = path.join(tempRoot, 'desktop-release.json');
fs.mkdirSync(destinationDirectory, { recursive: true });
fs.writeFileSync(path.join(destinationDirectory, asset), assetContent);
fs.writeFileSync(metadataPath, JSON.stringify({
version: '9.9.9',
tag: 'v9.9.9',
asset,
downloadUrl: `https://example.test/releases/download/v9.9.9/${asset}`,
publicPath: `/downloads/${asset}`,
sha256,
size: assetContent.length
}));
const runCheck = () => spawnSync(process.execPath, [
syncScript,
'--metadata', metadataPath,
'--destination-dir', destinationDirectory,
'--check-only'
], { encoding: 'utf8' });
test('已缓存安装包通过大小与 SHA256 校验', () => {
const result = runCheck();
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /Desktop release verified/);
});
test('损坏的安装包会让只读校验失败', () => {
fs.appendFileSync(path.join(destinationDirectory, asset), 'corrupt');
const result = runCheck();
assert.notStrictEqual(result.status, 0);
assert.match(result.stderr, /Desktop release check failed/);
});
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
console.log('\n========================================');
console.log('测试总结');
console.log('========================================');
console.log(`通过: ${results.passed}`);
console.log(`失败: ${results.failed}`);
process.exit(results.failed > 0 ? 1 : 0);
+11 -2
View File
@@ -7,6 +7,12 @@ const path = require('path');
const testFiles = [
'boundary-tests.js',
'production-utils-tests.js',
'domain-utils-tests.js',
'middleware-tests.js',
'architecture-tests.js',
'release-sync-tests.js',
'archive-tests.js',
'network-concurrent-tests.js',
'state-consistency-tests.js'
];
@@ -42,7 +48,9 @@ function runTest(file) {
const failMatch = output.match(/失败:\s*(\d+)/);
const passed = passMatch ? parseInt(passMatch[1]) : 0;
const failed = failMatch ? parseInt(failMatch[1]) : 0;
const parsedFailed = failMatch ? parseInt(failMatch[1]) : 0;
// A syntax error or early crash may not print the normal summary.
const failed = code !== 0 && parsedFailed === 0 ? 1 : parsedFailed;
results.files.push({
file,
@@ -91,7 +99,8 @@ async function runAllTests() {
console.log(`总计: 通过 ${results.total.passed}, 失败 ${results.total.failed}`);
console.log('');
if (results.total.failed > 0) {
const hasProcessFailure = results.files.some(file => file.exitCode !== 0);
if (results.total.failed > 0 || hasProcessFailure) {
console.log('存在失败的测试,请检查输出以了解详情。');
process.exit(1);
} else {
+9
View File
@@ -0,0 +1,9 @@
let archiverModulePromise = null;
async function createZipArchive(options = {}) {
archiverModulePromise ||= import('archiver');
const { ZipArchive } = await archiverModulePromise;
return new ZipArchive(options);
}
module.exports = { createZipArchive };
+26
View File
@@ -0,0 +1,26 @@
function normalizeNonNegativeInteger(rawValue, fallback = 0) {
const value = Number(rawValue);
if (!Number.isFinite(value) || value < 0) {
return Math.max(0, Number(fallback) || 0);
}
return Math.floor(value);
}
function parseBooleanLike(rawValue, fallback = false) {
if (rawValue === null || rawValue === undefined || rawValue === '') {
return fallback;
}
if (typeof rawValue === 'boolean') {
return rawValue;
}
const normalized = String(rawValue).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
module.exports = {
normalizeNonNegativeInteger,
parseBooleanLike
};
+92
View File
@@ -0,0 +1,92 @@
function parseDateTimeValue(value) {
if (!value || typeof value !== 'string') return null;
const directDate = new Date(value);
if (!Number.isNaN(directDate.getTime())) return directDate;
const normalizedDate = new Date(value.replace(' ', 'T'));
return Number.isNaN(normalizedDate.getTime()) ? null : normalizedDate;
}
function formatDateTimeForSqlite(date = new Date()) {
const target = date instanceof Date ? date : new Date(date);
const year = target.getFullYear();
const month = String(target.getMonth() + 1).padStart(2, '0');
const day = String(target.getDate()).padStart(2, '0');
const hours = String(target.getHours()).padStart(2, '0');
const minutes = String(target.getMinutes()).padStart(2, '0');
const seconds = String(target.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
function getDateKeyFromDate(date = new Date()) {
const target = date instanceof Date ? date : new Date(date);
if (Number.isNaN(target.getTime())) return null;
const year = target.getFullYear();
const month = String(target.getMonth() + 1).padStart(2, '0');
const day = String(target.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function getRecentDateKeys(days = 30, now = new Date()) {
const safeDays = Math.max(1, Math.floor(Number(days) || 30));
const keys = [];
for (let i = safeDays - 1; i >= 0; i -= 1) {
const date = new Date(now.getTime());
date.setDate(date.getDate() - i);
const key = getDateKeyFromDate(date);
if (key) keys.push(key);
}
return keys;
}
function getNextDownloadResetTime(lastResetAt, resetCycle) {
const baseDate = parseDateTimeValue(lastResetAt);
if (!baseDate) return null;
const next = new Date(baseDate.getTime());
if (resetCycle === 'daily') next.setDate(next.getDate() + 1);
else if (resetCycle === 'weekly') next.setDate(next.getDate() + 7);
else if (resetCycle === 'monthly') next.setMonth(next.getMonth() + 1);
else return null;
return next;
}
function normalizeTimeHHmm(value) {
if (typeof value !== 'string') return null;
const match = value.trim().match(/^(\d{2}):(\d{2})$/);
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}
function toMinutesOfDay(hhmm) {
const normalized = normalizeTimeHHmm(hhmm);
if (!normalized) return null;
const [hours, minutes] = normalized.split(':').map(Number);
return hours * 60 + minutes;
}
function isCurrentTimeInWindow(startTime, endTime, now = new Date()) {
const start = toMinutesOfDay(startTime);
const end = toMinutesOfDay(endTime);
if (start === null || end === null) return true;
const nowMinutes = now.getHours() * 60 + now.getMinutes();
if (start === end) return true;
if (start < end) return nowMinutes >= start && nowMinutes < end;
return nowMinutes >= start || nowMinutes < end;
}
module.exports = {
parseDateTimeValue,
formatDateTimeForSqlite,
getDateKeyFromDate,
getRecentDateKeys,
getNextDownloadResetTime,
normalizeTimeHHmm,
toMinutesOfDay,
isCurrentTimeInWindow
};
+68
View File
@@ -0,0 +1,68 @@
function normalizeClientIp(rawIp) {
const ip = String(rawIp || '').trim();
if (!ip) return '';
if (ip.startsWith('::ffff:')) return ip.slice(7);
return ip === '::1' ? '127.0.0.1' : ip;
}
function detectDeviceTypeFromUserAgent(userAgent = '') {
const mobilePattern = /(Mobile|Android|iPhone|iPad|iPod|Windows Phone|HarmonyOS|Mobi)/i;
return mobilePattern.test(String(userAgent || '')) ? 'mobile' : 'desktop';
}
function inferPlatformFromUserAgent(userAgent = '') {
const value = String(userAgent || '');
if (!value) return '未知平台';
if (/windows/i.test(value)) return 'Windows';
if (/macintosh|mac os x/i.test(value)) return 'macOS';
if (/android/i.test(value)) return 'Android';
if (/iphone|ipad|ios/i.test(value)) return 'iOS';
if (/linux/i.test(value)) return 'Linux';
return '未知平台';
}
function normalizeClientType(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return ['web', 'desktop', 'mobile', 'api'].includes(normalized) ? normalized : '';
}
function resolveClientType(clientType, userAgent = '') {
const normalized = normalizeClientType(clientType);
if (normalized) return normalized;
const userAgentValue = String(userAgent || '').toLowerCase();
if (
userAgentValue.includes('tauri')
|| userAgentValue.includes('electron')
|| userAgentValue.includes('wanwan-cloud-desktop')
|| userAgentValue.includes('玩玩云')
) {
return 'desktop';
}
return detectDeviceTypeFromUserAgent(userAgentValue) === 'mobile' ? 'mobile' : 'web';
}
function sanitizeDeviceText(value, maxLength = 120) {
return typeof value === 'string' ? value.trim().slice(0, maxLength) : '';
}
function buildDeviceName({ clientType, deviceName, platform }) {
const preferred = sanitizeDeviceText(deviceName, 120);
if (preferred) return preferred;
const platformText = sanitizeDeviceText(platform, 80) || '未知平台';
if (clientType === 'desktop') return `桌面客户端 · ${platformText}`;
if (clientType === 'mobile') return `移动端浏览器 · ${platformText}`;
if (clientType === 'api') return `API 客户端 · ${platformText}`;
return `网页端浏览器 · ${platformText}`;
}
module.exports = {
buildDeviceName,
detectDeviceTypeFromUserAgent,
inferPlatformFromUserAgent,
normalizeClientIp,
normalizeClientType,
resolveClientType,
sanitizeDeviceText
};
+107
View File
@@ -0,0 +1,107 @@
const {
parseDateTimeValue,
formatDateTimeForSqlite,
getNextDownloadResetTime
} = require('./datetime');
const MAX_DOWNLOAD_TRAFFIC_BYTES = 10 * 1024 * 1024 * 1024 * 1024;
function normalizeDownloadTrafficQuota(rawQuota) {
const parsedQuota = Number(rawQuota);
if (!Number.isFinite(parsedQuota)) return 0;
if (parsedQuota < 0) return -1;
return Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, Math.floor(parsedQuota));
}
function normalizeDownloadTrafficUsed(rawUsed, quota = 0) {
const parsedUsed = Number(rawUsed);
const normalizedUsed = Number.isFinite(parsedUsed) && parsedUsed > 0
? Math.floor(parsedUsed)
: 0;
return quota >= 0 ? Math.min(normalizedUsed, quota) : normalizedUsed;
}
function getDownloadTrafficState(user) {
const quota = normalizeDownloadTrafficQuota(user?.download_traffic_quota);
const used = normalizeDownloadTrafficUsed(user?.download_traffic_used, quota);
const isUnlimited = quota < 0;
return {
quota,
used,
isUnlimited,
remaining: isUnlimited ? Number.POSITIVE_INFINITY : Math.max(0, quota - used)
};
}
function resolveDownloadTrafficPolicyUpdates(user, now = new Date()) {
if (!user) {
return { updates: {}, hasUpdates: false, expired: false, resetApplied: false };
}
const updates = {};
let hasUpdates = false;
let expired = false;
let resetApplied = false;
const normalizedQuota = normalizeDownloadTrafficQuota(user.download_traffic_quota);
const normalizedUsed = normalizeDownloadTrafficUsed(user.download_traffic_used, normalizedQuota);
if (normalizedQuota !== Number(user.download_traffic_quota || 0)) {
updates.download_traffic_quota = normalizedQuota;
hasUpdates = true;
}
if (normalizedUsed !== Number(user.download_traffic_used || 0)) {
updates.download_traffic_used = normalizedUsed;
hasUpdates = true;
}
const resetCycle = ['none', 'daily', 'weekly', 'monthly'].includes(user.download_traffic_reset_cycle)
? user.download_traffic_reset_cycle
: 'none';
if (resetCycle !== (user.download_traffic_reset_cycle || 'none')) {
updates.download_traffic_reset_cycle = resetCycle;
hasUpdates = true;
}
const expiresAt = parseDateTimeValue(user.download_traffic_quota_expires_at);
if (normalizedQuota <= 0 && user.download_traffic_quota_expires_at) {
updates.download_traffic_quota_expires_at = null;
hasUpdates = true;
} else if (normalizedQuota > 0 && expiresAt && now >= expiresAt) {
updates.download_traffic_quota = -1;
updates.download_traffic_used = 0;
updates.download_traffic_quota_expires_at = null;
updates.download_traffic_reset_cycle = 'none';
updates.download_traffic_last_reset_at = null;
hasUpdates = true;
expired = true;
}
if (!expired && resetCycle !== 'none') {
const lastResetAt = user.download_traffic_last_reset_at;
if (!lastResetAt) {
updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now);
hasUpdates = true;
} else {
const nextResetAt = getNextDownloadResetTime(lastResetAt, resetCycle);
if (nextResetAt && now >= nextResetAt) {
updates.download_traffic_used = 0;
updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now);
hasUpdates = true;
resetApplied = true;
}
}
} else if (resetCycle === 'none' && user.download_traffic_last_reset_at) {
updates.download_traffic_last_reset_at = null;
hasUpdates = true;
}
return { updates, hasUpdates, expired, resetApplied };
}
module.exports = {
MAX_DOWNLOAD_TRAFFIC_BYTES,
normalizeDownloadTrafficQuota,
normalizeDownloadTrafficUsed,
getDownloadTrafficState,
resolveDownloadTrafficPolicyUpdates
};
+33 -26
View File
@@ -16,6 +16,7 @@
*/
const crypto = require('crypto');
const ENCRYPTION_PREFIX = 'enc:v1:';
/**
* 从环境变量获取加密密钥
@@ -111,8 +112,8 @@ function encryptSecret(plaintext) {
authTag
]);
// 返回 Base64 编码结果
return combined.toString('base64');
// 返回带版本前缀的 Base64 编码结果,避免旧明文被误判为密文。
return ENCRYPTION_PREFIX + combined.toString('base64');
} catch (error) {
console.error('[加密] 加密失败:', error);
throw new Error('数据加密失败: ' + error.message);
@@ -134,24 +135,26 @@ function encryptSecret(plaintext) {
* // 输出: 'my-secret-key'
*/
function decryptSecret(ciphertext) {
// 如果是 null 或 undefined,直接返回
if (!ciphertext) {
return ciphertext;
}
const rawValue = String(ciphertext);
const hasPrefix = rawValue.startsWith(ENCRYPTION_PREFIX);
const encodedValue = hasPrefix ? rawValue.slice(ENCRYPTION_PREFIX.length) : rawValue;
if (!hasPrefix && !isEncrypted(encodedValue)) {
if (/[+/=]/.test(rawValue)) {
throw new Error('数据解密失败: 疑似密文格式无效');
}
console.warn('[加密] 检测到未加密的旧密钥,建议重新保存以完成加密');
return rawValue;
}
try {
// 如果是 null 或 undefined,直接返回
if (!ciphertext) {
return ciphertext;
}
// 检查是否为加密格式(Base64)
// 如果不是 Base64,可能是旧数据(明文),直接返回
if (!/^[A-Za-z0-9+/=]+$/.test(ciphertext)) {
console.warn('[加密] 检测到未加密的密钥,建议重新加密');
return ciphertext;
}
// 获取加密密钥
const key = getEncryptionKey();
// 解析 Base64
const combined = Buffer.from(ciphertext, 'base64');
const combined = Buffer.from(encodedValue, 'base64');
// 提取各部分
// IV: 前 12 字节
@@ -175,13 +178,9 @@ function decryptSecret(ciphertext) {
return decrypted;
} catch (error) {
// 如果解密失败,可能是旧数据(明文),直接返回
console.error('[加密] 解密失败,可能是未加密的旧数据:', error.message);
// 在开发环境抛出错误,生产环境尝试返回原值
if (process.env.NODE_ENV === 'production') {
console.error('[加密] 生产环境中解密失败,返回原值(可能导致 OSS 连接失败)');
return ciphertext;
if (!hasPrefix && !/[+/=]/.test(rawValue)) {
console.warn('[加密] 旧格式数据解密失败,按未加密旧密钥处理:', error.message);
return rawValue;
}
throw new Error('数据解密失败: ' + error.message);
@@ -240,6 +239,10 @@ function isEncrypted(data) {
return false;
}
const encodedValue = data.startsWith(ENCRYPTION_PREFIX)
? data.slice(ENCRYPTION_PREFIX.length)
: data;
// 加密后的数据特征:
// 1. 是有效的 Base64
// 2. 长度至少为 (12 + 16) * 4/3 = 38 字符(IV + authTag 的 Base64
@@ -247,7 +250,11 @@ function isEncrypted(data) {
try {
// 尝试解码 Base64
const buffer = Buffer.from(data, 'base64');
if (!/^[A-Za-z0-9+/=]+$/.test(encodedValue) || encodedValue.length % 4 !== 0) {
return false;
}
const buffer = Buffer.from(encodedValue, 'base64');
// 检查长度(至少包含 IV + authTag
// AES-GCM: 12字节IV + 至少1字节密文 + 16字节authTag = 29字节
+98
View File
@@ -0,0 +1,98 @@
const path = require('path');
const DANGEROUS_EXTENSIONS = [
'.php', '.php3', '.php4', '.php5', '.phtml', '.phar',
'.jsp', '.jspx', '.jsw', '.jsv', '.jspf',
'.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx',
'.htaccess', '.htpasswd'
];
function sanitizeInput(str) {
if (typeof str !== 'string') return str;
let sanitized = str.replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;'
}[char]));
sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, '');
return sanitized.replace(/\x00/g, '');
}
function decodeHtmlEntities(str) {
if (typeof str !== 'string') return str;
const entityMap = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
'#x27': "'",
'#x2F': '/',
'#x60': '`'
};
const decodeOnce = (input) =>
input.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, code) => {
if (code[0] === '#') {
const isHex = code[1]?.toLowerCase() === 'x';
const num = isHex ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
return Number.isNaN(num) ? match : String.fromCharCode(num);
}
const mapped = entityMap[code];
return mapped !== undefined ? mapped : match;
});
let output = str;
let decoded = decodeOnce(output);
while (decoded !== output) {
output = decoded;
decoded = decodeOnce(output);
}
return output;
}
function escapeHtml(str) {
if (typeof str !== 'string') return str;
return str.replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;'
}[char]));
}
function isSafePathSegment(name) {
return (
typeof name === 'string' &&
name.length > 0 &&
name.length <= 255 &&
!name.includes('..') &&
!/[/\\]/.test(name) &&
!/[\x00-\x1F]/.test(name)
);
}
function isFileExtensionSafe(filename) {
if (!filename || typeof filename !== 'string') return false;
const ext = path.extname(filename).toLowerCase();
const nameLower = filename.toLowerCase();
if (DANGEROUS_EXTENSIONS.includes(ext)) return false;
if (['.htaccess', '.htpasswd'].includes(nameLower)) return false;
return !DANGEROUS_EXTENSIONS.some((dangerExt) => nameLower.includes(`${dangerExt}.`));
}
module.exports = {
sanitizeInput,
decodeHtmlEntities,
escapeHtml,
isSafePathSegment,
isFileExtensionSafe
};
+137
View File
@@ -0,0 +1,137 @@
const { AsyncLocalStorage } = require('async_hooks');
const { randomUUID } = require('crypto');
const pino = require('pino');
const requestStorage = new AsyncLocalStorage();
const SENSITIVE_KEY_PATTERN = /(authorization|cookie|password|passwd|secret|token|api[_-]?key|access[_-]?key)/i;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
const logger = pino({
level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
base: {
service: 'wanwanyun-backend',
pid: process.pid
},
timestamp: pino.stdTimeFunctions.isoTime,
redact: {
paths: [
'*.authorization',
'*.cookie',
'*.password',
'*.token',
'*.refreshToken',
'*.access_key_secret',
'*.oss_access_key_secret',
'*.smtp_password',
'req.headers.authorization',
'req.headers.cookie'
],
censor: '[REDACTED]'
}
});
function redactString(value) {
return value
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
.replace(/\b(password|passwd|secret|token|api[_-]?key|access[_-]?key)\s*[:=]\s*[^\s,;&]+/gi, '$1=[REDACTED]');
}
function redactLogValue(value, depth = 0, seen = new WeakSet()) {
if (depth > 5) return '[MAX_DEPTH]';
if (typeof value === 'string') return redactString(value);
if (value === null || value === undefined || typeof value !== 'object') return value;
if (value instanceof Error) {
return {
type: value.name,
message: redactString(value.message || ''),
stack: redactString(value.stack || ''),
code: value.code
};
}
if (seen.has(value)) return '[CIRCULAR]';
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => redactLogValue(item, depth + 1, seen));
}
const result = {};
for (const [key, child] of Object.entries(value)) {
result[key] = SENSITIVE_KEY_PATTERN.test(key)
? '[REDACTED]'
: redactLogValue(child, depth + 1, seen);
}
return result;
}
function getRequestContext() {
return requestStorage.getStore() || {};
}
function getRequestId(req) {
return req?.id || getRequestContext().requestId || null;
}
function normalizeRequestId(value) {
const candidate = Array.isArray(value) ? value[0] : value;
return typeof candidate === 'string' && REQUEST_ID_PATTERN.test(candidate)
? candidate
: randomUUID();
}
function requestContextMiddleware(req, res, next) {
const requestId = normalizeRequestId(req.headers['x-request-id']);
const startedAt = process.hrtime.bigint();
req.id = requestId;
req.log = logger.child({ requestId });
res.setHeader('X-Request-ID', requestId);
requestStorage.run({ requestId }, () => {
res.once('finish', () => {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
const level = res.statusCode >= 500 ? 'error' : (res.statusCode >= 400 ? 'warn' : 'info');
req.log[level]({
method: req.method,
path: req.path,
statusCode: res.statusCode,
durationMs: Number(durationMs.toFixed(2))
}, 'request completed');
});
next();
});
}
let consoleBridgeInstalled = false;
function installConsoleBridge() {
if (consoleBridgeInstalled) return;
if (process.env.NODE_ENV !== 'production' && process.env.LOG_FORMAT !== 'json') return;
consoleBridgeInstalled = true;
const levels = {
log: 'info',
info: 'info',
warn: 'warn',
error: 'error',
debug: 'debug'
};
for (const [consoleMethod, level] of Object.entries(levels)) {
console[consoleMethod] = (...args) => {
const values = redactLogValue(args);
const firstMessage = typeof values[0] === 'string' ? values[0] : 'console message';
logger[level]({
requestId: getRequestId(),
values
}, firstMessage);
};
}
}
module.exports = {
getRequestId,
installConsoleBridge,
logger,
redactLogValue,
requestContextMiddleware
};
+115
View File
@@ -0,0 +1,115 @@
const MONTHS = Object.freeze({
Jan: 0,
Feb: 1,
Mar: 2,
Apr: 3,
May: 4,
Jun: 5,
Jul: 6,
Aug: 7,
Sep: 8,
Oct: 9,
Nov: 10,
Dec: 11
});
function parseDownloadTrafficLogTime(line) {
if (!line || typeof line !== 'string') return null;
const match = line.match(/\[(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})\s*([+-]\d{4})?\]/);
if (!match) return null;
const month = MONTHS[match[2]];
if (month === undefined) return null;
const year = Number(match[3]);
const day = Number(match[1]);
const hour = Number(match[4]);
const minute = Number(match[5]);
const second = Number(match[6]);
if ([year, day, hour, minute, second].some((value) => !Number.isFinite(value))) return null;
let utcMillis = Date.UTC(year, month, day, hour, minute, second);
const timezone = match[7];
if (timezone && /^[+-]\d{4}$/.test(timezone)) {
const sign = timezone[0] === '+' ? 1 : -1;
const timezoneHours = Number(timezone.slice(1, 3));
const timezoneMinutes = Number(timezone.slice(3, 5));
if (timezoneHours > 23 || timezoneMinutes > 59) return null;
utcMillis -= sign * ((timezoneHours * 60) + timezoneMinutes) * 60 * 1000;
}
const parsed = new Date(utcMillis);
if (Number.isNaN(parsed.getTime())) return null;
// Date.UTC normalizes impossible dates; reject them instead of charging the wrong day.
const offsetMinutes = timezone
? (timezone[0] === '+' ? 1 : -1) * ((Number(timezone.slice(1, 3)) * 60) + Number(timezone.slice(3, 5)))
: 0;
const localDate = new Date(parsed.getTime() + (offsetMinutes * 60 * 1000));
if (
localDate.getUTCFullYear() !== year
|| localDate.getUTCMonth() !== month
|| localDate.getUTCDate() !== day
|| localDate.getUTCHours() !== hour
|| localDate.getUTCMinutes() !== minute
|| localDate.getUTCSeconds() !== second
) {
return null;
}
return parsed;
}
function parseDownloadTrafficLine(line, fallbackDate = new Date()) {
if (!line || typeof line !== 'string') return null;
const trimmed = line.trim();
if (!trimmed || !/\bGET\b/i.test(trimmed)) return null;
const statusMatch = trimmed.match(/"\s*(\d{3})\s+(\d+|-)\b/);
if (!statusMatch) return null;
const statusCode = Number(statusMatch[1]);
const bytesSent = statusMatch[2] === '-' ? 0 : Number(statusMatch[2]);
if (![200, 206].includes(statusCode) || !Number.isFinite(bytesSent) || bytesSent <= 0) return null;
let objectKey = null;
const requestMatch = trimmed.match(/"(?:GET|HEAD)\s+([^" ]+)\s+HTTP\//i);
if (requestMatch?.[1]) {
let requestPath = requestMatch[1];
const queryIndex = requestPath.indexOf('?');
if (queryIndex >= 0) requestPath = requestPath.slice(0, queryIndex);
requestPath = requestPath.replace(/^https?:\/\/[^/]+/i, '').replace(/^\/+/, '');
try {
requestPath = decodeURIComponent(requestPath);
} catch {
// Keep the raw path when the log contains malformed percent encoding.
}
objectKey = requestPath || null;
}
if (!objectKey) {
const keyMatch = trimmed.match(/\buser_(\d+)\/[^\s"]+/);
objectKey = keyMatch?.[0] || null;
}
if (!objectKey) return null;
const userMatch = objectKey.match(/(?:^|\/)user_(\d+)\//);
if (!userMatch) return null;
const userId = Number(userMatch[1]);
if (!Number.isSafeInteger(userId) || userId <= 0) return null;
return {
userId,
bytes: Math.floor(bytesSent),
objectKey,
eventAt: parseDownloadTrafficLogTime(trimmed) || fallbackDate
};
}
module.exports = {
parseDownloadTrafficLine,
parseDownloadTrafficLogTime
};
+34
View File
@@ -0,0 +1,34 @@
const path = require('path');
function isPathInside(parent, child) {
const relativePath = path.relative(parent, child);
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
}
function normalizeVirtualPath(rawPath) {
if (typeof rawPath !== 'string') return null;
let decoded = rawPath;
try {
decoded = decodeURIComponent(rawPath);
} catch {
// The traversal checks below still apply to malformed encoded input.
}
if (decoded.includes('\x00') || decoded.toLowerCase().includes('%00')) return null;
const unifiedPath = decoded.replace(/\\/g, '/');
if (/(^|\/)\.\.(\/|$)/.test(unifiedPath)) return null;
let normalized = path.posix.normalize(unifiedPath);
if (normalized === '' || normalized === '.') normalized = '/';
if (!normalized.startsWith('/')) normalized = `/${normalized}`;
normalized = normalized.replace(/\/+$/g, '');
return normalized || '/';
}
module.exports = {
isPathInside,
normalizeVirtualPath
};
+54
View File
@@ -0,0 +1,54 @@
const { normalizeVirtualPath } = require('./path-safety');
const SHARE_CODE_PATTERN = /^[A-Za-z0-9]{6,32}$/;
function isValidShareCode(code) {
return typeof code === 'string' && SHARE_CODE_PATTERN.test(code);
}
function isPathWithinShare(requestPath, share) {
if (!requestPath || !share) return false;
const normalizedRequest = normalizeVirtualPath(requestPath);
const normalizedShare = normalizeVirtualPath(share.share_path);
if (!normalizedRequest || !normalizedShare) return false;
if (share.share_type === 'file') return normalizedRequest === normalizedShare;
const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : `${normalizedShare}/`;
return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix);
}
function parseShareIpWhitelist(rawValue) {
if (typeof rawValue !== 'string') return [];
return rawValue
.split(/[\s,;]+/)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 100);
}
function isShareIpAllowed(clientIp, whitelist = []) {
if (!Array.isArray(whitelist) || whitelist.length === 0) return true;
if (!clientIp) return false;
for (const rule of whitelist) {
const normalizedRule = String(rule || '').trim();
if (!normalizedRule) continue;
if (normalizedRule === clientIp) return true;
if (normalizedRule.endsWith('*')) {
const prefix = normalizedRule.slice(0, -1);
if (prefix && clientIp.startsWith(prefix)) return true;
}
}
return false;
}
module.exports = {
isPathWithinShare,
isShareIpAllowed,
isValidShareCode,
parseShareIpWhitelist
};
+8 -9
View File
@@ -53,21 +53,20 @@ class StorageUsageCache {
*/
static async updateUsage(userId, deltaSize) {
try {
// 使用 SQL 原子操作,避免并发问题
const result = UserDB.update(userId, {
// 使用原始 SQL,因为 update 方法不支持表达式
// 注意:这里需要在数据库层执行 UPDATE ... SET storage_used = storage_used + ?
});
const numericDelta = Number(deltaSize);
if (!Number.isFinite(numericDelta)) {
throw new Error('deltaSize 必须是有效数字');
}
// 直接执行 SQL 更新
// 直接执行 SQL 原子更新,并保证不小于 0
const { db } = require('../database');
db.prepare(`
UPDATE users
SET storage_used = storage_used + ?
SET storage_used = MAX(COALESCE(storage_used, 0) + ?, 0)
WHERE id = ?
`).run(deltaSize, userId);
`).run(numericDelta, userId);
console.log(`[存储缓存] 用户 ${userId} 存储变化: ${deltaSize > 0 ? '+' : ''}${deltaSize} 字节`);
console.log(`[存储缓存] 用户 ${userId} 存储变化: ${numericDelta > 0 ? '+' : ''}${numericDelta} 字节`);
return true;
} catch (error) {
console.error('[存储缓存] 更新失败:', error);
+43
View File
@@ -0,0 +1,43 @@
function sanitizeHttpBaseUrl(rawValue) {
if (!rawValue) return null;
try {
const url = new URL(rawValue);
if (!['http:', 'https:'].includes(url.protocol)) return null;
url.search = '';
url.hash = '';
url.pathname = url.pathname.replace(/\/+$/, '');
return url.toString();
} catch {
return null;
}
}
function buildHttpDownloadUrl(rawBaseUrl, filePath) {
const baseUrl = sanitizeHttpBaseUrl(rawBaseUrl);
if (!baseUrl || !filePath) return null;
try {
const url = new URL(baseUrl);
const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
const safeSegments = normalizedPath
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment));
const safePath = safeSegments.length ? `/${safeSegments.join('/')}` : '';
const basePath = url.pathname.replace(/\/+$/, '');
url.pathname = `${basePath}${safePath || '/'}` || '/';
url.search = '';
url.hash = '';
return url.toString();
} catch {
return null;
}
}
module.exports = {
buildHttpDownloadUrl,
sanitizeHttpBaseUrl
};
+45
View File
@@ -0,0 +1,45 @@
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
function normalizeVersion(rawVersion, fallback = '0.0.0') {
const value = String(rawVersion || '').trim();
return value || fallback;
}
function compareLooseVersion(left, right) {
const normalize = (value) => normalizeVersion(value, '0.0.0')
.replace(/^v/i, '')
.split('.')
.map((part) => parseInt(part, 10))
.map((num) => (Number.isFinite(num) ? num : 0));
const a = normalize(left);
const b = normalize(right);
const size = Math.max(a.length, b.length);
for (let i = 0; i < size; i += 1) {
const av = a[i] || 0;
const bv = b[i] || 0;
if (av > bv) return 1;
if (av < bv) return -1;
}
return 0;
}
function normalizeReleaseNotes(rawValue) {
return String(rawValue || '')
.replace(/\\r\\n/g, '\n')
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\n')
.trim();
}
function normalizeSha256(rawValue) {
const digest = String(rawValue || '').trim().toLowerCase();
return SHA256_PATTERN.test(digest) ? digest : '';
}
module.exports = {
compareLooseVersion,
normalizeReleaseNotes,
normalizeSha256,
normalizeVersion
};
+8
View File
@@ -0,0 +1,8 @@
# Design QA
final result: passed
- File workspace: verified at 1280x720 with a full-height drop target and unboxed icon layout.
- Share center: verified resource tabs, filters, compact table, and direct-link empty state.
- Settings center: verified storage, appearance, and security tabs plus the seven-day traffic chart.
- Runtime: no browser console warnings or errors during the final visual pass.
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+118
View File
@@ -0,0 +1,118 @@
# 玩玩云桌面客户端(Tauri + Vue + TypeScript
这是玩玩云网盘的桌面端,基于 `Tauri 2 + Vue 3 + TypeScript` 开发。
默认对接地址:`https://cs.workyai.cn`
## 功能概览
- 账号登录(复用网页端账号体系)
- 文件列表浏览、目录切换、全局搜索
- 新建文件夹、重命名、删除
- 分享链接 / 直链创建与管理
- 下载(支持断点续传)
- 本地目录同步(手动/定时)
- 客户端版本检测与更新下载
## 环境要求
- Node.js 18+(建议 20 LTS
- Rust stable(建议通过 `rustup` 安装)
- Tauri CLI(项目已在 `package.json` 中声明)
可选:Linux 交叉编译 Windows 时还需要:
- `mingw-w64`
- `nsis`
## 开发运行
```bash
cd desktop-client
npm install
npm run tauri dev
```
## 对接你的网盘后端
### 1. 修改 API 基础地址
当前默认地址在 `desktop-client/src/App.vue`
- `appConfig.baseUrl`(默认 `https://cs.workyai.cn`
改成你的服务地址后重新运行/打包。
### 2. 后端需提供的核心接口
桌面端通过现有 Web API 工作,至少需要这些接口:
- `POST /api/login`
- `POST /api/logout`
- `GET /api/user/profile`
- `GET /api/files`
- `GET /api/files/search`
- `POST /api/files/mkdir`
- `POST /api/files/rename`
- `POST /api/files/delete`
- `POST /api/share/create`
- `GET /api/share/my`
- `DELETE /api/share/:id`
- `POST /api/direct-link/create`
- `GET /api/client/desktop-update`
- 分片上传相关接口(客户端走 `api_upload_file_resumable`
### 3. 鉴权与跨域要求
- 使用 Cookie / Session 鉴权
- 服务端允许桌面端跨域并携带凭据(credentials
- 建议启用 HTTPS
## 构建打包
```bash
cd desktop-client
npm install
npm run tauri build
```
构建产物示例:
- `desktop-client/src-tauri/target/release/bundle/nsis/*.exe`
## Linux 交叉编译 Windowsx64
```bash
cd desktop-client
rustup target add x86_64-pc-windows-gnu
npm run tauri build -- --target x86_64-pc-windows-gnu
```
如需生成安装包,请确保系统安装 `mingw-w64``nsis`
## 更新发布接入(与本项目后端配合)
1. 把新安装包上传到服务端下载目录(示例:`frontend/downloads/`)。
2. 在后台设置中更新:
- `desktop_update.latest_version`
- `desktop_update.installer_url`
- `desktop_update.release_notes`
3. 客户端会通过 `GET /api/client/desktop-update` 检查更新并下载新包。
## 常见问题
### Q1:改了接口地址但还是连旧地址?
确认修改的是 `desktop-client/src/App.vue` 中的 `appConfig.baseUrl`,并重新构建。
### Q2:为什么更新接口返回有新版本,但客户端不弹更新?
检查:
- `latest_version` 是否确实大于客户端当前版本
- `installer_url` 是否可访问
- 客户端能否访问后端 `desktop-update` 接口
### Q3:分享/直链重复创建问题
当前后端已支持“同一用户同一路径复用已有链接”。若要生成新链接,请先删除旧链接再创建。
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f7f9fc" />
<title>玩玩云 Desktop</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1772
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "desktop-client",
"private": true,
"version": "0.1.38",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@fontsource-variable/noto-sans-sc": "^5.3.0",
"@phosphor-icons/vue": "^2.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2",
"vue": "^3.5.13"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+5843
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "desktop-client"
version = "0.1.38"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "desktop_client_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["time"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "multipart", "stream", "rustls-tls"] }
urlencoding = "2.1"
walkdir = "2.5"
rusqlite = { version = "0.31", features = ["bundled"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"dialog:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Some files were not shown because too many files have changed in this diff Show More