diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..538af3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.wrangler/ +.dev.vars +*.log diff --git a/LICENSE b/LICENSE index df6f6fc..8047ee7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 EdgeStash +Copyright (c) 2026 EdgeStash Contributors +Copyright (c) 2024 hhy2021 (original EdgeStash) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..2462844 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +# EdgeStash (Enhanced Fork) + +基于 **Cloudflare Workers + R2 + KV** 的轻量多用户云盘。 +**单文件部署**(`worker.js`),无需数据库、无需服务器,全部数据都在你自己的 Cloudflare 账户里。 + +> 本仓库是 [hhy-2021/EdgeStash](https://github.com/hhy-2021/EdgeStash)(MIT)的增强分支。 +> 在上游基础上修复了多个已知 Bug 与安全问题,并新增分享 v2、配额、批量管理等能力。 +> 感谢原作者的项目与所有在 issue 中反馈问题的用户。 + +--- + +## 目录 + +- [为什么 fork](#为什么-fork) +- [特性总览](#特性总览) +- [相对上游的修复与增强](#相对上游的修复与增强) +- [部署](#部署) +- [使用指南](#使用指南) +- [API 速查](#api-速查) +- [安全设计](#安全设计) +- [常见问题](#常见问题) +- [License](#license) + +--- + +## 为什么 fork + +上游 EdgeStash 是一个优秀的单文件 R2 云盘,但在实际使用中遇到了一些问题: +特殊字符文件名在预览/下载时 404、文件夹删不掉、删除接口误报成功、分享只能分享单个文件、 +音视频预览无法拖动进度条、以及若干安全隐患(zip-slip、XSS、分享清单泄露等)。 + +本分支在上游代码基础上做了系统性的修复与增强,**保持单文件、零依赖、Dashboard 可直接粘贴部署**的形态不变。 + +## 特性总览 + +### 文件管理 + +- **多用户与强隔离**:首个注册用户自动成为管理员,管理员创建/审核用户,每个用户的文件按 `<用户名>/` 前缀在 R2 层面完全隔离 +- **自由用户名**:不要求邮箱格式,字母/数字/`_ . @ -`,1-64 位,全局唯一 +- **文件/文件夹**:新建文件夹(一次可建多级 `a/b/c`)、重命名(文件夹递归搬移)、删除(递归删除) +- **统一上传弹窗**:单按钮入口,文件(多选)/文件夹(保留目录结构)双 tab,先选后传、确认上传 +- **文件夹整体上传**:浏览器选择文件夹后自动逐级建目录并批量上传 +- **文件夹打包下载**:按文件夹一键下载 ZIP,服务端流式打包,不占 Worker 内存 +- **全盘容量上限**:可配置总容量(默认 9.5 GB),超限上传返回明确的中文提示 +- **在线预览**:图片 / 视频 / 音频(**支持拖动进度条**,HTTP Range 206)/ PDF / 文本代码 / JSON / Markdown / Word (.docx) + +### 分享 v2 + +- **单文件 / 整个文件夹 / 多文件** 三种分享类型(多文件自动重名去重) +- **游客逐个下载** 或 **一键打包 ZIP** 下载,无需登录 +- 分享密码、有效期(1小时/1天/1个月/永久),到期自动失效(410) +- 加密分享的文件清单在验证密码前**不下发**,防止清单泄露 +- 浏览/下载次数统计,管理后台随时撤销 +- 48 位长随机 token,链接不可猜测 + +### 管理后台 + +- 统计数据(总分享数、浏览量、下载量) +- 用户管理:创建用户、**一次性密码重置**(随机密码仅显示一次,服务端只存哈希) +- 分享管理:列表 + **多选批量删除**(严格校验 ID,不存在的计入失败而非静默假成功) +- 全盘容量设置 + +### 其他 + +- 深色玻璃拟态 UI,移动端自适应 +- 页面响应 `no-cache`(部署后刷新即最新),API `no-store`,文件响应 `nosniff` +- 自助注册:站点无用户时始终开放(用于引导管理员);已有用户后由 `REGISTER_ENABLED` 开关控制(默认关闭) + +## 相对上游的修复与增强 + +### 上游已知问题修复 + +| 上游 Issue | 状态 | +|---|---| +| #8 文件夹删不掉 | 已修复(删除路径归一化 + 递归删除 + 禁止删根目录) | +| #6 特殊文件名(空格/逗号/`#`/`?`)预览下载报错 | 已修复(URL 按段编码 + 内联事件参数编码 + 路由双形态匹配) | +| #5 支持文件夹整体上传 | 已实现(弹窗内选择文件夹,保留目录结构) | +| #4 注册功能 + 用户名 + 管理员重置密码 | 已实现(自注册走 `REGISTER_ENABLED` 开关;重置密码一次性展示) | +| #1 / #2 UI 改版 | 已被全新 UI 取代 | + +README 中提到的「管理员登录无反应」已知 Bug 也已修复。 + +### Bug 修复(部分) + +- **删除假成功**:删除不存在的文件/文件夹现在正确返回 404 +- **音视频拖动进度条**:完整实现 HTTP Range(206 Partial Content),单段 Range 精确响应,多段降级 200 +- **路径编码回归**:特殊字符路径在所有 API(列表/预览/下载/打包)下均可正常工作 +- **面包屑编码显示**:进入含特殊字符的文件夹不再显示 `%2F` 之类的编码串 + +### 安全加固 + +- **独立 JWT 签名密钥**(可选绑定 `JWT_SECRET`):不再复用 `ADMIN_PASSWORD`, + 防止令牌被离线爆破后泄露管理密码;未配置时自动回退旧模式,兼容上游部署 +- **JWT exp 规范化**:按 RFC 7519 使用秒级时间戳(兼容读取旧毫秒令牌) +- **登录防枚举**:统一「用户名或密码错误」提示,不暴露账号是否存在 +- **zip-slip 防护**:分享创建拒绝 `..` 路径,ZIP条目名逐段清洗 +- **XSS 防护**:Markdown 预览经 DOMPurify 消毒后渲染 +- **Cookie 安全**:`HttpOnly` + `Secure` + `SameSite=Strict` +- **nosniff**:所有文件响应禁止 MIME 嗅探 + +## 部署 + +### 方式一:Cloudflare Dashboard 粘贴部署(推荐新手) + +1. **创建 R2 存储桶**:Cloudflare 控制台 → R2 → 创建存储桶(名称随意,如 `edgestash`) +2. **创建 KV 命名空间**:Workers 和 Pages → KV → 创建命名空间(名称随意) +3. **创建 Worker**:Workers 和 Pages → 创建应用程序 → 创建 Worker(名称随意) +4. **粘贴代码**:Worker 页面 → 编辑代码 → 将本仓库 `worker.js` 全文粘贴进去 → 部署 +5. **配置绑定**:Worker → 设置 → 变量和机密 + + | 类型 | 变量名 | 说明 | + |---|---|---| + | R2 绑定 | `R2_BUCKET` | 选择第 1 步创建的桶 | + | KV 绑定 | `KV_STORE` | 选择第 2 步创建的命名空间 | + | Secret | `ADMIN_PASSWORD` | JWT 签名密钥回退(推荐配置独立 `JWT_SECRET`;**不会明文存储**) | + | Secret(可选,推荐) | `JWT_SECRET` | 独立 JWT 签名密钥,任意长随机字符串 | + | 环境变量(可选) | `REGISTER_ENABLED` | 站点已有用户后,设为 `true` 开放自助注册 | + +6. **完成**:访问 `https://..workers.dev` → 打开注册页,**第一个注册的账号自动成为管理员**(用户名密码自定)。此后注册遵循 `REGISTER_ENABLED` 开关,新用户为普通用户,由管理员在后台创建或审核 +7. 可选:在 Worker → 设置 → 域和路由 绑定自定义域名 + +### 方式二:Wrangler 命令行 + +```sh +git clone <本仓库> +cd <仓库目录> + +# 编辑 wrangler.toml:改成你的 R2 桶名与 KV namespace id +npx wrangler secret put ADMIN_PASSWORD +# 推荐再加一个独立签名密钥: +npx wrangler secret put JWT_SECRET + +npx wrangler deploy +``` + +> `wrangler.toml` 中预留了 `REGISTER_ENABLED` 开关,按需修改。 + +## 使用指南 + +- **上传**:工具栏「上传」→ 弹窗中选「选择文件」或「选择文件夹」tab → 选中后确认摘要 → 开始上传 +- **分享**:选中文件(或进文件夹)→「创建分享」→ 从云盘选或本地上传 → 设密码/有效期 → 生成链接; + 文件夹与多文件分享的访客页支持逐个下载或打包 ZIP +- **打包下载**:文件夹列表页的下载按钮会下载整个文件夹的 ZIP +- **管理后台**:管理员登录后右上角进入,可管理用户(含一次性密码重置)、批量删除分享、设置全盘容量 + +## API 速查 + +| 方法 | 路径 | 说明 | +|---|---|---| +| POST | `/api/login` | 登录(`{email, password}`),成功后种 HttpOnly Cookie | +| POST | `/api/logout` | 退出登录 | +| GET | `/api/auth/check` | 检查当前登录状态 | +| POST | `/api/register` | 注册:站点无用户时首注册即管理员;已有用户需 `REGISTER_ENABLED=true` | +| GET | `/api/files/<目录>` | 列目录 | +| POST | `/api/files/<目标目录>` | 上传文件(multipart,字段名 `file`) | +| DELETE | `/api/files/<路径>` | 删除文件/递归删除文件夹(目标不存在返回 404) | +| PUT | `/api/files/<路径>` | 重命名(文件夹递归搬移) | +| POST | `/api/folders` | 新建文件夹(支持 `a/b/c` 多级) | +| GET | `/api/download/<路径>` | 下载(支持 Range) | +| GET | `/api/preview/<路径>` | 预览(音视频支持 Range 206 拖动进度) | +| GET | `/api/download-folder/<路径>` | 整个文件夹流式打包 ZIP 下载 | +| POST | `/api/share` | 创建分享(`{type: file/folder/multi, filePath/path, password, expiresIn}`) | +| GET | `/api/share/` | 游客取分享信息(加密分享不回文件清单) | +| POST | `/api/share//verify` | 验证分享密码,返回完整清单 | +| POST | `/api/share//file` | 游客下载单个文件(支持 Range) | +| POST | `/api/share//zip` | 游客打包下载(文件夹/多文件) | +| GET | `/api/admin/stats` | 统计数据(管理员) | +| GET/PUT | `/api/admin/storage` | 查看/设置全盘容量上限(管理员) | +| GET | `/api/admin/shares` | 列出所有分享(管理员) | +| DELETE | `/api/admin/shares/` | 删除单个分享(管理员) | +| POST | `/api/admin/shares/batch-delete` | 批量删除分享(`{shareIds: [...]}`,管理员) | +| GET/POST/DELETE | `/api/admin/users[...]` | 用户管理(管理员) | +| POST | `/api/admin/users/<用户名>/reset-password` | 重置密码,返回一次性密码(管理员) | + +## 安全设计 + +- 密码仅存 PBKDF2-SHA256 哈希(每密码独立随机盐,恒定时间比较);历史版本的无盐 SHA-256 哈希会在用户下次登录时自动升级,任何人都无法查看明文密码 +- JWT (HS256) 签名密钥推荐使用独立的 `JWT_SECRET`(未配置时回退 `ADMIN_PASSWORD`) +- 会话 Cookie:`HttpOnly` + `Secure` + `SameSite=Strict` +- 用户文件按 `<用户名>/` 前缀强隔离;分享记录校验 owner,游客仅能访问分享内文件 +- 上传文件名去除路径分隔符;文件夹名禁止 `.` / `..` / 空段;分享与 ZIP 打包双重防 zip-slip +- 加密分享在密码验证前不下发文件清单 +- Markdown 预览经 DOMPurify 消毒;全部响应带 `nosniff`;HTML `no-cache`、API `no-store` + +## 常见问题 + +**Q:管理员是怎么产生的?** +全新部署后站点没有任何用户,此时注册页始终开放——**第一个注册的账号自动成为管理员**。之后注册需 `REGISTER_ENABLED=true`,且都只是普通用户。 + +**Q:忘了管理员密码怎么办?** +在 KV 中删除 `user:<管理员用户名>` 这条记录,站点即回到「无用户」状态,重新注册即可再次引导出管理员(用户名可以换新的)。 + +**Q:免费额度够用吗?** +Cloudflare 免费版 Workers 每天 10 万请求、R2 免费 10 GB 存储,个人/小团队使用基本够用。 + +**Q:和上游怎么同步?** +本分支基于上游 2026-01 版 worker.js 全面增强;上游后续更新可视情况择优合并。 + +## License + +MIT(见 [LICENSE](LICENSE)),继承并兼容上游 [hhy-2021/EdgeStash](https://github.com/hhy-2021/EdgeStash) 的 MIT 协议。 + +--- + +* Maintained by EdgeStash Contributors diff --git a/imgs/1.png b/imgs/1.png deleted file mode 100644 index 2c28569..0000000 Binary files a/imgs/1.png and /dev/null differ diff --git a/imgs/2.png b/imgs/2.png deleted file mode 100644 index dda5407..0000000 Binary files a/imgs/2.png and /dev/null differ diff --git a/imgs/3.png b/imgs/3.png deleted file mode 100644 index f24ecda..0000000 Binary files a/imgs/3.png and /dev/null differ diff --git a/imgs/4.png b/imgs/4.png deleted file mode 100644 index a3fa714..0000000 Binary files a/imgs/4.png and /dev/null differ diff --git a/imgs/5.png b/imgs/5.png deleted file mode 100644 index b496590..0000000 Binary files a/imgs/5.png and /dev/null differ diff --git a/readme.md b/readme.md deleted file mode 100644 index 189987f..0000000 --- a/readme.md +++ /dev/null @@ -1,153 +0,0 @@ -# EdgeStash | 一款全新的Cloudflare R2云盘,支持带密码分享、查看下载量、管理用户等功能 - - -**EdgeStash** 是一个功能强大、易于部署的私有云盘解决方案,完全构建在 Cloudflare 的全球网络之上。它利用 **Cloudflare Workers**、**R2 存储** 和 **KV 存储**,为您提供一个安全、快速且低成本的个人或团队文件存储与分享平台。 - -EdgeStash**支持带密码分享文件、在线预览docx或pdf文档、后台管理授权用户、查看分享文件浏览/下载量!** - -这个项目旨在提供一个“一键部署”的体验,您只需要一个 Cloudflare 账户,即可在几分钟内拥有属于自己团队的云盘服务。 - -## 🛸 预览 - - - -| | | -| ---------------------------------- | ---------------------------------- | -| ![Demo](.//imgs/1.png) | ![Demo](./imgs/2.png) | -| ![Demo](.//imgs/3.png) | ![Demo](./imgs/4.png) | - -### 视频:功能快速预览 + 部署教程 -https://www.bilibili.com/video/BV1s2FPzqEdA/ - -## ✨ 核心特性 - -- **👑多用户系统**: - - **管理员**:拥有最高权限,可通过密码登录,管理用户、分享链接和查看统计数据。 - - **授权用户**:由管理员授权创建(邮箱+密码),授权用户仅可浏览文件首页,无法进入管理后台。 - - **普通用户**:使用密码下载被分享文件,无密码保护的分享文件可直接下载。 - -- **⏳灵活的文件分享**: - - **密码保护**:为分享链接设置访问密码。 - - **有效期设置**:支持 1 小时、1 天、1 个月或永久有效。 - - **访问统计**:跟踪每个分享链接的浏览和下载次数。 - -- **🧸在线文件预览**: - - 无需下载,直接在浏览器中预览多种文件格式。 - - **文档**:Word (.docx), PDF, Markdown (.md), 纯文本 (.txt)。 - - **图片**:JPG, PNG, GIF, WebP, SVG 等。 - - **代码/数据**:JSON 文件自动格式化。 - - **音视频**:MP4, WebM, MP3, WAV 等。 - -- **📁强大的文件管理**: - - 支持文件和文件夹的创建、重命名、移动和删除。 - - 拖拽式文件上传和多文件上传。 - - 面包屑导航,轻松在不同层级目录间穿梭。 - -- **🔧管理后台**: - - **数据统计**:实时查看总分享数、总浏览量和总下载量。 - - **用户管理**:轻松添加新用户、撤销现有用户授权。 - - **分享管理**:集中查看和删除所有已创建的分享链接。 - -- **💡现代化界面**: - - 紫蓝色系渐变配色,美观、专业。 - - 完全响应式设计,适配桌面、平板和手机。 - - 流畅的动画和操作反馈提示。 - -- **🙈安全与隐私**: - - 所有数据存储在您自己的 R2 和 KV 中,完全掌控。 - - 通过 JWT (JSON Web Tokens) 进行安全的会话管理。 - -## 🚀 部署指南 - -部署 EdgeStash 非常简单,全程在 Cloudflare Dashboard 中完成。 - -### 前置要求 - -- 一个 Cloudflare 账户。 -- 已开通 R2 和 Workers 、 KV 服务。 - -### 部署步骤 - -| 配置 | 变量名 | 说明 | -| :----- | :----------------------------- | :------------------------- | -| `R2` | `R2_BUCKET` | 存放文件的地方,名称随意| -| `KV` | `KV_STORE`| 存放链接的地方,名称随意| -| `管理员密码` | `ADMIN_PASSWORD`| 你想设置什么都行 | - - -1. **创建 R2 存储桶** - - 登录 Cloudflare -> R2 -> 创建存储桶。 - - 记下您的存储桶名称(例如 `edgestash-files`)。 - -2. **创建 KV 命名空间** - - 登录 Cloudflare -> Workers 和 Pages -> KV -> 创建命名空间。 - - 记下您的命名空间名称(例如 `edgestash-kv`)。 - -3. **创建 Worker** - - 登录 Cloudflare -> Workers 和 Pages -> 创建应用程序 -> 创建 Worker。 - - 为您的 Worker 命名(例如 `edgestash`),然后点击 **部署**。 - -4. **上传代码** - - 在 Worker 页面,点击 **编辑代码**。 - - 将本项目提供的 `worker.js` 文件内容完整粘贴进去。 - - 点击 **部署**。 - -5. **配置绑定** - - 返回 Worker 概览页面,点击 **设置** -> **变量和机密**。 - - **配置环境变量**: - - `ADMIN_PASSWORD`:设置您的管理员登录密码。 - - **配置 R2 绑定**: - - 变量名称:`R2_BUCKET` - - R2 存储桶:选择您在第 1 步创建的存储桶。 - - **配置 KV 绑定**: - - 变量名称:`KV_STORE` - - KV 命名空间:选择您在第 2 步创建的命名空间。 - -6. **完成!** - - 访问您的 Worker URL (`https://..workers.dev`) 即可开始使用! -7. **绑定你自己的域名!** - -### 😤已知的小bug -管理员登录时如果点击`登录`按钮无反应,切换到`用户登录`,把`用户登录`的`邮箱`**输入框内容清空**,然后重新进行管理员登录即可~ - - -## 📚 API 接口参考 - -EdgeStash 通过一套 RESTful API 提供服务,以下是核心接口列表。 - -| 方法 | 路径 | 说明 | -| :----- | :----------------------------- | :------------------------- | -| **认证** | | | -| `POST` | `/api/login` | 管理员或用户登录 | -| `POST` | `/api/logout` | 退出登录 | -| `GET` | `/api/auth/check` | 检查当前登录状态 | -| **文件管理** | | | -| `GET` | `/api/files/*` | 获取指定路径下的文件和文件夹 | -| `POST` | `/api/files/*` | 上传文件到指定路径 | -| `PUT` | `/api/files/*` | 重命名文件或文件夹 | -| `DELETE`| `/api/files/*` | 删除文件或文件夹 | -| `POST` | `/api/folders` | 创建新文件夹 | -| `GET` | `/api/download/*` | 下载文件 | -| `GET` | `/api/preview/*` | 获取文件内容用于在线预览 | -| **分享管理** | | | -| `POST` | `/api/share` | 为文件创建分享链接 | -| `GET` | `/api/share/:id` | 获取分享链接信息 | -| `POST` | `/api/share/:id/download` | 下载分享的文件 | -| **管理后台** | | | -| `GET` | `/api/admin/stats` | 获取统计数据 | -| `GET` | `/api/admin/shares` | 列出所有分享链接 | -| `DELETE`| `/api/admin/shares/:id` | 删除指定的分享链接 | -| `GET` | `/api/admin/users` | 列出所有授权用户 | -| `POST` | `/api/admin/users` | 创建新用户 | -| `DELETE`| `/api/admin/users/:email` | 删除指定用户 | - -## 📜 开源协议 - -本项目采用 **MIT License** 开源。 -您可以自由地使用、修改、分发本项目的代码,但需要在您的衍生作品中包含原始的版权和许可声明。 - ---- -## Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=hhy-2021/EdgeStash&type=date&legend=top-left)](https://www.star-history.com/#hhy-2021/EdgeStash&type=date&legend=top-left) diff --git a/worker.js b/worker.js index bc59b02..9233309 100644 --- a/worker.js +++ b/worker.js @@ -30,14 +30,73 @@ function generateId(length = 16) { } /** - * Hash a password using SHA-256 + * Password hashing. + * 新格式:PBKDF2-SHA256(每密码随机盐)→ "pbkdf2$$$" + * 旧格式:无盐 SHA-256 hex(上游遗留),登录校验成功后自动升级为新格式 */ -async function hashPassword(password) { +const PBKDF2_ITERATIONS = 10000; // Worker CPU 友好的迭代次数(免费版 10ms 限制内) + +function bytesToB64u(bytes) { + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +function b64uToBytes(s) { + s = s.replace(/-/g, '+').replace(/_/g, '/'); + while (s.length % 4) s += '='; + return Uint8Array.from(atob(s), c => c.charCodeAt(0)); +} + +async function pbkdf2Derive(password, saltBytes, iterations) { const encoder = new TextEncoder(); - const data = encoder.encode(password); + const key = await crypto.subtle.importKey( + 'raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits'] + ); + const bits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', hash: 'SHA-256', salt: saltBytes, iterations }, + key, 256 + ); + return new Uint8Array(bits); +} + +async function sha256Hex(text) { + const data = new TextEncoder().encode(text); const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +async function hashPassword(password) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const bits = await pbkdf2Derive(password, salt, PBKDF2_ITERATIONS); + return 'pbkdf2$' + PBKDF2_ITERATIONS + '$' + bytesToB64u(salt) + '$' + bytesToB64u(bits); +} + +function timingSafeEqualStr(a, b) { + if (a.length !== b.length) return false; + let r = 0; + for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i); + return r === 0; +} + +async function verifyPassword(password, stored) { + if (typeof stored !== 'string') return false; + if (stored.startsWith('pbkdf2$')) { + const parts = stored.split('$'); + if (parts.length !== 4) return false; + const iterations = parseInt(parts[1], 10); + if (!iterations || iterations < 1 || iterations > 10000000) return false; + try { + const salt = b64uToBytes(parts[2]); + const actual = bytesToB64u(await pbkdf2Derive(password, salt, iterations)); + return timingSafeEqualStr(actual, parts[3]); + } catch (e) { + return false; + } + } + // 旧格式:无盐 SHA-256 hex + const legacy = await sha256Hex(password); + return timingSafeEqualStr(legacy, stored); } /** @@ -101,8 +160,11 @@ async function verifyJWT(token, secret) { const payload = JSON.parse(atob(encodedPayload.replace(/-/g, '+').replace(/_/g, '/'))); - // Check expiration - if (payload.exp && Date.now() > payload.exp) return null; + // Check expiration(兼容旧毫秒令牌与新秒令牌,RFC 7519 要求秒) + if (payload.exp) { + const expMs = payload.exp > 1e12 ? payload.exp : payload.exp * 1000; + if (Date.now() > expMs) return null; + } return payload; } catch (e) { @@ -231,6 +293,7 @@ function jsonResponse(data, status = 200, headers = {}) { status, headers: { 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', ...headers } }); @@ -244,6 +307,10 @@ function htmlResponse(html, status = 200, headers = {}) { status, headers: { 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-cache', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'SAMEORIGIN', + 'Referrer-Policy': 'no-referrer', ...headers } }); @@ -256,51 +323,43 @@ function htmlResponse(html, status = 200, headers = {}) { async function handleLogin(request, env) { try { const body = await request.json(); - const { email, password, isAdmin } = body; - - if (isAdmin) { - // Admin login - if (password === env.ADMIN_PASSWORD) { - const token = await createJWT( - { role: 'admin', exp: Date.now() + 24 * 60 * 60 * 1000 }, - env.ADMIN_PASSWORD - ); - return jsonResponse( - { success: true, role: 'admin' }, - 200, - { 'Set-Cookie': `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400` } - ); - } - return jsonResponse({ success: false, message: '管理员密码错误' }, 401); - } else { - // User login - if (!email || !password) { - return jsonResponse({ success: false, message: '请输入邮箱和密码' }, 400); - } - - const userData = await env.KV_STORE.get(`user:${email}`); - if (!userData) { - return jsonResponse({ success: false, message: '用户不存在' }, 401); - } - - const user = JSON.parse(userData); - const passwordHash = await hashPassword(password); - - if (user.passwordHash !== passwordHash) { - return jsonResponse({ success: false, message: '密码错误' }, 401); - } - - const token = await createJWT( - { email: user.email, role: 'user', exp: Date.now() + 24 * 60 * 60 * 1000 }, - env.ADMIN_PASSWORD - ); - - return jsonResponse( - { success: true, role: 'user', email: user.email }, - 200, - { 'Set-Cookie': `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400` } - ); + const { email, password } = body; + + if (!email || !password) { + return jsonResponse({ success: false, message: '请输入用户名和密码' }, 400); + } + + let userData = await env.KV_STORE.get(`user:${email}`); + + if (!userData) { + return jsonResponse({ success: false, message: '用户名或密码错误' }, 401); + } + + const user = JSON.parse(userData); + const ok = await verifyPassword(password, user.passwordHash); + + if (!ok) { + return jsonResponse({ success: false, message: '用户名或密码错误' }, 401); } + + // 旧格式(无盐 SHA-256)登录成功后自动升级为 PBKDF2 + if (!user.passwordHash.startsWith('pbkdf2$')) { + user.passwordHash = await hashPassword(password); + await env.KV_STORE.put(`user:${email}`, JSON.stringify(user)); + } + + // 按用户在 KV 中的实际角色签发 JWT(管理员用户 role:'admin' 即获得管理员权限) + const role = user.role || 'user'; + const token = await createJWT( + { email: user.email, role, exp: Math.floor(Date.now() / 1000) + 86400 }, + env.JWT_SECRET || env.ADMIN_PASSWORD + ); + + return jsonResponse( + { success: true, role, email: user.email }, + 200, + { 'Set-Cookie': `token=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400` } + ); } catch (e) { return jsonResponse({ success: false, message: '登录失败: ' + e.message }, 500); } @@ -310,7 +369,7 @@ async function handleLogout() { return jsonResponse( { success: true }, 200, - { 'Set-Cookie': 'token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' } + { 'Set-Cookie': 'token=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0' } ); } @@ -320,7 +379,7 @@ async function verifyAuth(request, env) { if (!token) return null; - return await verifyJWT(token, env.ADMIN_PASSWORD); + return await verifyJWT(token, env.JWT_SECRET || env.ADMIN_PASSWORD); } async function requireAuth(request, env) { @@ -343,15 +402,26 @@ async function requireAdmin(request, env) { // FILE MANAGEMENT HANDLERS // ============================================================================ +// 每个用户文件互相隔离:该用户所有 R2 key 都落在 / 前缀下 +function userScope(auth) { + return (auth && auth.email ? String(auth.email) : '_anonymous') + '/'; +} + +// URL 路径段是百分号编码的(中文/空格等),使用前必须解码;畸形编码时原样返回 +function safeDecode(s) { + try { return decodeURIComponent(s); } catch (e) { return s; } +} + async function handleListFiles(request, env, path) { const auth = await requireAuth(request, env); if (auth instanceof Response) return auth; try { - // Normalize path - let prefix = path || ''; - if (prefix && !prefix.endsWith('/')) prefix += '/'; - if (prefix.startsWith('/')) prefix = prefix.slice(1); + // Normalize path(虚拟路径 + 用户隔离前缀 = 真实 R2 key) + const scope = userScope(auth); + let rel = (path || '').replace(/^\/+/, ''); + if (rel && !rel.endsWith('/')) rel += '/'; + const prefix = scope + rel; const listed = await env.R2_BUCKET.list({ prefix, delimiter: '/' }); @@ -363,7 +433,7 @@ async function handleListFiles(request, env, path) { for (const folderPath of listed.delimitedPrefixes) { const name = folderPath.slice(prefix.length, -1); if (name) { - folders.push({ name, path: '/' + folderPath.slice(0, -1) }); + folders.push({ name, path: '/' + folderPath.slice(scope.length, -1) }); } } } @@ -372,11 +442,11 @@ async function handleListFiles(request, env, path) { if (listed.objects) { for (const obj of listed.objects) { const name = obj.key.slice(prefix.length); - if (name && !name.includes('/')) { + if (name && name !== '.folder' && !name.includes('/')) { const previewType = getPreviewType(name); files.push({ name, - path: '/' + obj.key, + path: '/' + obj.key.slice(scope.length), size: obj.size, sizeFormatted: formatFileSize(obj.size), lastModified: obj.uploaded.toISOString(), @@ -386,7 +456,7 @@ async function handleListFiles(request, env, path) { } } - return jsonResponse({ success: true, files, folders, currentPath: '/' + prefix.slice(0, -1) || '/' }); + return jsonResponse({ success: true, files, folders, currentPath: '/' + prefix.slice(scope.length, -1) }); } catch (e) { return jsonResponse({ success: false, message: '获取文件列表失败: ' + e.message }, 500); } @@ -404,18 +474,43 @@ async function handleUploadFile(request, env, path) { return jsonResponse({ success: false, message: '没有上传文件' }, 400); } - // Normalize path - let filePath = path || ''; - if (filePath.startsWith('/')) filePath = filePath.slice(1); + // 每用户存储配额检查(0 = 不限) + const quotaBytes = await getUserQuotaBytes(env, auth.email); + if (quotaBytes > 0) { + const usedBytes = await getUserUsedBytes(env, auth.email); + if (usedBytes + file.size > quotaBytes) { + return jsonResponse({ + success: false, + message: `存储空间不足:配额 ${formatFileSize(quotaBytes)},已用 ${formatFileSize(usedBytes)},本文件 ${formatFileSize(file.size)}` + }, 413); + } + } + + // 全盘总上限检查(默认 9.5GB,对应 R2 免费额度;提示语按角色区分) + const capBytes = await getStorageCapBytes(env); + if (capBytes > 0) { + const globalUsed = await getGlobalUsedBytes(env); + if (globalUsed + file.size > capBytes) { + const msg = auth.role === 'admin' + ? '云盘存储已超过上限(已用 ' + formatFileSize(globalUsed) + ' / 上限 ' + formatFileSize(capBytes) + '),请在管理后台调整上限' + : '云盘免费额度不够:已用 ' + formatFileSize(globalUsed) + ' / 上限 ' + formatFileSize(capBytes) + ',若需要调整请联系管理员'; + return jsonResponse({ success: false, message: msg }, 413); + } + } + + // Normalize path(用户前缀隔离;吞掉多余的前导斜杠) + let filePath = (path || '').replace(/^\/+/, ''); if (filePath && !filePath.endsWith('/')) filePath += '/'; - const key = filePath + file.name; + const scope = userScope(auth); + const safeName = (file.name || 'file').split('/').pop() || 'file'; + const key = scope + filePath + safeName; await env.R2_BUCKET.put(key, file.stream(), { httpMetadata: { contentType: file.type || getMimeType(file.name) } }); - return jsonResponse({ success: true, message: '文件上传成功', path: '/' + key }); + return jsonResponse({ success: true, message: '文件上传成功', path: '/' + key.slice(scope.length) }); } catch (e) { return jsonResponse({ success: false, message: '文件上传失败: ' + e.message }, 500); } @@ -426,12 +521,15 @@ async function handleDeleteFile(request, env, path) { if (auth instanceof Response) return auth; try { - let key = path || ''; - if (key.startsWith('/')) key = key.slice(1); + let rel = (path || '').replace(/^\/+/, '').replace(/\/+$/, ''); + if (!rel) { + return jsonResponse({ success: false, message: '不能删除根目录' }, 400); + } + const key = userScope(auth) + rel; // Check if it's a folder (has objects with this prefix) const listed = await env.R2_BUCKET.list({ prefix: key + '/', limit: 1 }); - + if (listed.objects && listed.objects.length > 0) { // It's a folder, delete all contents recursively let cursor; @@ -442,6 +540,12 @@ async function handleDeleteFile(request, env, path) { } cursor = batch.truncated ? batch.cursor : null; } while (cursor); + } else { + // 不是文件夹:目标文件必须真实存在,否则 404(避免"假成功"误导调用方) + const head = await env.R2_BUCKET.head(key); + if (!head) { + return jsonResponse({ success: false, message: '文件或文件夹不存在' }, 404); + } } // Try to delete the file itself @@ -465,27 +569,49 @@ async function handleRenameFile(request, env, path) { return jsonResponse({ success: false, message: '请提供新名称' }, 400); } - let oldKey = path || ''; - if (oldKey.startsWith('/')) oldKey = oldKey.slice(1); + if (newName.includes('/')) { + return jsonResponse({ success: false, message: '名称不能包含 /' }, 400); + } + + const scope = userScope(auth); + let rel = (path || '').replace(/^\/+/, ''); + const oldKey = scope + rel; const parentPath = oldKey.includes('/') ? oldKey.substring(0, oldKey.lastIndexOf('/') + 1) : ''; const newKey = parentPath + newName; - // Get the old file + // 情况一:普通文件,直接搬 const oldObject = await env.R2_BUCKET.get(oldKey); - if (!oldObject) { - return jsonResponse({ success: false, message: '文件不存在' }, 404); + if (oldObject) { + await env.R2_BUCKET.put(newKey, oldObject.body, { + httpMetadata: oldObject.httpMetadata + }); + await env.R2_BUCKET.delete(oldKey); + + return jsonResponse({ success: true, message: '重命名成功', newPath: '/' + newKey.slice(scope.length) }); } - // Copy to new location - await env.R2_BUCKET.put(newKey, oldObject.body, { - httpMetadata: oldObject.httpMetadata - }); + // 情况二:文件夹(前缀下所有对象——含 .folder 占位——整体搬到新前缀) + const first = await env.R2_BUCKET.list({ prefix: oldKey + '/', limit: 1 }); + if (!first.objects || first.objects.length === 0) { + return jsonResponse({ success: false, message: '文件不存在' }, 404); + } - // Delete old file - await env.R2_BUCKET.delete(oldKey); + let cursor; + do { + const batch = await env.R2_BUCKET.list({ prefix: oldKey + '/', cursor }); + for (const obj of (batch.objects || [])) { + const dest = newKey + '/' + obj.key.slice(oldKey.length + 1); + const body = await env.R2_BUCKET.get(obj.key); + if (body) { + await env.R2_BUCKET.put(dest, body.body, { httpMetadata: body.httpMetadata }); + await env.R2_BUCKET.delete(obj.key); + } + } + cursor = batch.truncated ? batch.cursor : null; + } while (cursor); - return jsonResponse({ success: true, message: '重命名成功', newPath: '/' + newKey }); + return jsonResponse({ success: true, message: '重命名成功', newPath: '/' + newKey.slice(scope.length) }); } catch (e) { return jsonResponse({ success: false, message: '重命名失败: ' + e.message }, 500); } @@ -503,42 +629,110 @@ async function handleCreateFolder(request, env) { return jsonResponse({ success: false, message: '请提供文件夹路径' }, 400); } - if (folderPath.startsWith('/')) folderPath = folderPath.slice(1); - if (!folderPath.endsWith('/')) folderPath += '/'; + // 归一:'/' 是路径分隔符,不能作为文件夹名;按段拆分后重组 + const segs = String(folderPath).split('/').filter(Boolean); + if (segs.length === 0) { + return jsonResponse({ success: false, message: '文件夹名不能为空(/ 是路径分隔符,不能作为名称)' }, 400); + } + if (segs.some(s => s === '.' || s === '..')) { + return jsonResponse({ success: false, message: '文件夹名不能是 . 或 ..' }, 400); + } + + const cleanPath = segs.join('/') + '/'; - // Create an empty placeholder file to represent the folder - await env.R2_BUCKET.put(folderPath + '.folder', new Uint8Array(0)); + // Create an empty placeholder file to represent the folder(用户前缀隔离) + await env.R2_BUCKET.put(userScope(auth) + cleanPath + '.folder', new Uint8Array(0)); - return jsonResponse({ success: true, message: '文件夹创建成功', path: '/' + folderPath.slice(0, -1) }); + return jsonResponse({ success: true, message: '文件夹创建成功', path: '/' + segs.join('/') }); } catch (e) { return jsonResponse({ success: false, message: '创建文件夹失败: ' + e.message }, 500); } } +// ============================================================================ +// FILE DOWNLOAD / PREVIEW(支持 Range/206,音视频可拖动进度条、部分播放器可用) +// ============================================================================ + +// 解析 Range 头:成功返回 {start,end};无/非法/越界返回 null +function parseRangeHeader(header, size) { + if (!header) return null; + const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); + if (!m || (m[1] === '' && m[2] === '')) return null; + let start, end; + if (m[1] === '') { + // bytes=-N:最后 N 字节 + const suffix = parseInt(m[2], 10); + if (suffix <= 0) return null; + start = Math.max(0, size - suffix); + end = size - 1; + } else { + start = parseInt(m[1], 10); + end = m[2] === '' ? size - 1 : Math.min(parseInt(m[2], 10), size - 1); + } + if (start >= size || start > end) return null; + return { start, end }; +} + +// 按请求的 Range 头从 R2 取对象 +async function getRangedObject(env, key, request) { + const rangeHeader = request.headers.get('Range'); + // 多段 Range(如 bytes=0-1,5-6)不处理,降级为 200 全量返回 + if (!rangeHeader || rangeHeader.indexOf(',') >= 0) { + const object = await env.R2_BUCKET.get(key); + if (!object) return { object: null }; + return { object, status: 200, range: null }; + } + const head = await env.R2_BUCKET.head(key); + if (!head) return { object: null }; + const range = parseRangeHeader(rangeHeader, head.size); + if (!range) return { object: null, status: 416, size: head.size }; + const object = await env.R2_BUCKET.get(key, { range: { offset: range.start, length: range.end - range.start + 1 } }); + if (!object) return { object: null }; + return { object, status: 206, range: { start: range.start, end: range.end, size: head.size } }; +} + +// 组装文件响应(含 Range 206 头) +function buildFileResponse(object, status, range, filename, disposition, extraHeaders) { + const headers = { + 'Content-Type': object.httpMetadata?.contentType || getMimeType(filename), + 'Accept-Ranges': 'bytes', + 'X-Content-Type-Options': 'nosniff', + ...(extraHeaders || {}) + }; + if (disposition) headers['Content-Disposition'] = disposition; + if (range) { + headers['Content-Range'] = 'bytes ' + range.start + '-' + range.end + '/' + range.size; + headers['Content-Length'] = String(range.end - range.start + 1); + } else { + headers['Content-Length'] = object.size; + } + return new Response(object.body, { status, headers }); +} + +function rangeNotSatisfiable(size) { + return new Response(null, { status: 416, headers: { 'Content-Range': 'bytes */' + size } }); +} + async function handleDownloadFile(request, env, path) { const auth = await verifyAuth(request, env); if (!auth) { return jsonResponse({ success: false, message: '未授权' }, 401); } - + try { - let key = path || ''; - if (key.startsWith('/')) key = key.slice(1); - - const object = await env.R2_BUCKET.get(key); + let rel = (path || '').replace(/^\/+/, ''); + const key = userScope(auth) + rel; + + const { object, status, range, size } = await getRangedObject(env, key, request); if (!object) { + if (status === 416) return rangeNotSatisfiable(size); return jsonResponse({ success: false, message: '文件不存在' }, 404); } - + const filename = key.split('/').pop(); - - return new Response(object.body, { - headers: { - 'Content-Type': object.httpMetadata?.contentType || getMimeType(filename), - 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, - 'Content-Length': object.size - } - }); + + return buildFileResponse(object, status, range, filename, + 'attachment; filename="' + encodeURIComponent(filename) + '"'); } catch (e) { return jsonResponse({ success: false, message: '下载失败: ' + e.message }, 500); } @@ -550,25 +744,21 @@ async function handlePreviewFile(request, env, path) { if (!auth) { return jsonResponse({ success: false, message: '未授权' }, 401); } - + try { - let key = path || ''; - if (key.startsWith('/')) key = key.slice(1); - - const object = await env.R2_BUCKET.get(key); + let rel = (path || '').replace(/^\/+/, ''); + const key = userScope(auth) + rel; + + const { object, status, range, size } = await getRangedObject(env, key, request); if (!object) { + if (status === 416) return rangeNotSatisfiable(size); return jsonResponse({ success: false, message: '文件不存在' }, 404); } - + const filename = key.split('/').pop(); - const contentType = object.httpMetadata?.contentType || getMimeType(filename); - - return new Response(object.body, { - headers: { - 'Content-Type': contentType, - 'Content-Length': object.size, - 'Cache-Control': 'private, max-age=3600' - } + + return buildFileResponse(object, status, range, filename, null, { + 'Cache-Control': 'private, max-age=3600' }); } catch (e) { return jsonResponse({ success: false, message: '预览失败: ' + e.message }, 500); @@ -579,83 +769,246 @@ async function handlePreviewFile(request, env, path) { // SHARE HANDLERS // ============================================================================ +// ============================================================================ +// SHARE HANDLERS(支持单文件 / 文件夹 / 多文件分享 + 游客逐个下载 + 打包 ZIP) +// ============================================================================ + +async function getShareRecord(env, shareId) { + const raw = await env.KV_STORE.get('share:' + shareId); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch (e) { + return null; + } +} + +function shareExpired(share) { + return share.expiresAt && Date.now() > share.expiresAt; +} + +// 兼容 JSON / form 两种提交(游客页用 form POST + iframe 触发浏览器原生流式下载) +async function readShareCredentials(request) { + const ct = request.headers.get('Content-Type') || ''; + try { + if (ct.includes('application/json')) { + const body = await request.json(); + return { password: body.password || '', path: body.path || '' }; + } + const form = await request.formData(); + return { password: form.get('password') || '', path: form.get('path') || '' }; + } catch (e) { + return { password: '', path: '' }; + } +} + +async function checkSharePasswordWith(share, password) { + if (!share.passwordHash) return null; + if (!password) return jsonResponse({ success: false, message: '请输入密码' }, 401); + const ok = await verifyPassword(password, share.passwordHash); + if (!ok) return jsonResponse({ success: false, message: '密码错误' }, 401); + return null; +} + +// 汇总分享包含的文件清单 [{key,name,size}](文件夹分享实时列举) +async function listShareEntries(env, share) { + if (share.type === 'folder') { + const entries = []; + let cursor; + do { + const batch = await env.R2_BUCKET.list({ prefix: share.folderPath, cursor }); + for (const obj of (batch.objects || [])) { + const name = obj.key.slice(share.folderPath.length); + if (!name || name === '.folder' || name.endsWith('/.folder')) continue; + entries.push({ key: obj.key, name, size: obj.size }); + } + cursor = batch.truncated ? batch.cursor : null; + } while (cursor); + return entries; + } + if (share.type === 'multi') { + return (share.items || []).map(it => ({ key: it.key, name: it.name, size: it.size })); + } + return [{ key: share.filePath, name: share.fileName, size: share.fileSize }]; +} + +async function bumpShareStats(env, share, shareId, field) { + share[field] = (share[field] || 0) + 1; + await env.KV_STORE.put('share:' + shareId, JSON.stringify(share)); + const statKey = field === 'viewCount' ? 'stats:totalViews' : 'stats:totalDownloads'; + const total = parseInt(await env.KV_STORE.get(statKey) || '0'); + await env.KV_STORE.put(statKey, String(total + 1)); +} + async function handleCreateShare(request, env) { const auth = await requireAuth(request, env); if (auth instanceof Response) return auth; - + try { const body = await request.json(); - const { filePath, password, expiresIn } = body; - - if (!filePath) { - return jsonResponse({ success: false, message: '请提供文件路径' }, 400); - } - - // Verify file exists - let key = filePath; - if (key.startsWith('/')) key = key.slice(1); - - const object = await env.R2_BUCKET.head(key); - if (!object) { - return jsonResponse({ success: false, message: '文件不存在' }, 404); - } - - const shareId = generateId(12); + const password = body.password; + const expiresIn = body.expiresIn; + const scope = userScope(auth); + + let shareType = body.type; + if (!shareType && Array.isArray(body.items) && body.items.length) shareType = 'multi'; + if (!shareType) shareType = 'file'; + const shareData = { - shareId, - filePath: key, - fileName: key.split('/').pop(), - fileSize: object.size, + shareId: null, + owner: auth.email, + type: shareType, passwordHash: password ? await hashPassword(password) : null, expiresAt: getExpirationTime(expiresIn || '1d'), viewCount: 0, downloadCount: 0, createdAt: Date.now() }; - - await env.KV_STORE.put(`share:${shareId}`, JSON.stringify(shareData)); - + + if (shareType === 'file') { + if (!body.filePath) { + return jsonResponse({ success: false, message: '请提供文件路径' }, 400); + } + let key = body.filePath; + if (key.startsWith('/')) key = key.slice(1); + key = scope + key; + const object = await env.R2_BUCKET.head(key); + if (!object) { + return jsonResponse({ success: false, message: '文件不存在' }, 404); + } + shareData.filePath = key; + shareData.fileName = key.split('/').pop(); + shareData.fileSize = object.size; + } else if (shareType === 'folder') { + if (!body.folderPath) { + return jsonResponse({ success: false, message: '请提供文件夹路径' }, 400); + } + const segs = String(body.folderPath).split('/').filter(Boolean); + if (!segs.length || segs.some(s => s === '.' || s === '..')) { + return jsonResponse({ success: false, message: '文件夹路径无效' }, 400); + } + const folderKey = scope + segs.join('/') + '/'; + // 确认文件夹存在(.folder 占位 或 前缀下有对象),并统计大小 + let count = 0, total = 0, cursor; + do { + const batch = await env.R2_BUCKET.list({ prefix: folderKey, cursor }); + for (const obj of (batch.objects || [])) { count++; total += obj.size; } + cursor = batch.truncated ? batch.cursor : null; + } while (cursor); + const hasPlaceholder = await env.R2_BUCKET.head(folderKey + '.folder'); + if (count === 0 && !hasPlaceholder) { + return jsonResponse({ success: false, message: '文件夹不存在' }, 404); + } + shareData.folderPath = folderKey; + shareData.folderName = segs[segs.length - 1]; + shareData.fileName = shareData.folderName; + shareData.fileSize = total; + shareData.fileCount = count; + } else if (shareType === 'multi') { + const list = Array.isArray(body.items) ? body.items : []; + if (list.length === 0) { + return jsonResponse({ success: false, message: '请提供要分享的文件' }, 400); + } + if (list.length > 100) { + return jsonResponse({ success: false, message: '一次最多分享 100 个文件' }, 400); + } + const items = []; + for (const p of list) { + let key = String(p || ''); + if (key.startsWith('/')) key = key.slice(1); + key = scope + key; + const object = await env.R2_BUCKET.head(key); + if (!object) { + return jsonResponse({ success: false, message: '文件不存在: ' + String(p) }, 404); + } + items.push({ key, name: key.split('/').pop(), size: object.size }); + } + // 同名 basename 去重(不同目录同名文件也能通过 /file 精确命中) + const usedNames = {}; + let dupSeq = 0; + for (const it of items) { + if (usedNames[it.name]) { + dupSeq++; + it.name = dupSeq + '_' + it.name; + } + usedNames[it.name] = true; + } + shareData.items = items; + shareData.fileName = items.length + ' 个文件'; + shareData.fileCount = items.length; + shareData.fileSize = items.reduce((s, it) => s + it.size, 0); + } else { + return jsonResponse({ success: false, message: '未知分享类型' }, 400); + } + + const shareId = generateId(48); // 足够长的随机 token:链接不可猜测 + shareData.shareId = shareId; + await env.KV_STORE.put('share:' + shareId, JSON.stringify(shareData)); + // Update stats const totalShares = parseInt(await env.KV_STORE.get('stats:totalShares') || '0'); await env.KV_STORE.put('stats:totalShares', String(totalShares + 1)); - + return jsonResponse({ success: true, shareId, - shareUrl: `/s/${shareId}` + shareUrl: '/s/' + shareId }); } catch (e) { return jsonResponse({ success: false, message: '创建分享链接失败: ' + e.message }, 500); } } +// 分享信息 payload(带密码的分享 files 仅在验密码后返回——防清单泄露) +async function shareInfoPayload(env, share, includeFiles) { + const entries = await listShareEntries(env, share); + const files = entries.map(e => ({ + path: e.name, + name: e.name.split('/').pop(), + size: e.size, + sizeFormatted: formatFileSize(e.size) + })); + return { + success: true, + type: share.type || 'file', + fileName: share.fileName, + fileSize: share.fileSize, + fileSizeFormatted: formatFileSize(share.fileSize), + fileCount: files.length, + requiresPassword: !!share.passwordHash, + expiresAt: share.expiresAt, + files: includeFiles ? files : null + }; +} + async function handleGetShareInfo(request, env, shareId) { try { - const shareData = await env.KV_STORE.get(`share:${shareId}`); - if (!shareData) { + const share = await getShareRecord(env, shareId); + if (!share) { return jsonResponse({ success: false, message: '分享链接不存在' }, 404); } - - const share = JSON.parse(shareData); - - // Check expiration - if (share.expiresAt && Date.now() > share.expiresAt) { + + if (shareExpired(share)) { return jsonResponse({ success: false, message: '分享链接已过期' }, 410); } - - // Update view count - share.viewCount++; - await env.KV_STORE.put(`share:${shareId}`, JSON.stringify(share)); - - // Update global stats - const totalViews = parseInt(await env.KV_STORE.get('stats:totalViews') || '0'); - await env.KV_STORE.put('stats:totalViews', String(totalViews + 1)); - + + const entries = await listShareEntries(env, share); + const files = entries.map(e => ({ + path: e.name, + name: e.name.split('/').pop(), + size: e.size, + sizeFormatted: formatFileSize(e.size) + })); + + await bumpShareStats(env, share, shareId, 'viewCount'); + return jsonResponse({ success: true, + type: share.type || 'file', fileName: share.fileName, fileSize: share.fileSize, fileSizeFormatted: formatFileSize(share.fileSize), + fileCount: files.length, requiresPassword: !!share.passwordHash, expiresAt: share.expiresAt }); @@ -664,88 +1017,178 @@ async function handleGetShareInfo(request, env, shareId) { } } +async function handleShareVerify(request, env, shareId) { + try { + const share = await getShareRecord(env, shareId); + if (!share) { + return jsonResponse({ success: false, message: '分享链接不存在' }, 404); + } + if (shareExpired(share)) { + return jsonResponse({ success: false, message: '分享链接已过期' }, 410); + } + const creds = await readShareCredentials(request); + const pwErr = await checkSharePasswordWith(share, creds.password); + if (pwErr) return pwErr; + return jsonResponse(await shareInfoPayload(env, share, true)); + } catch (e) { + return jsonResponse({ success: false, message: '校验失败: ' + e.message }, 500); + } +} + async function handleShareDownload(request, env, shareId) { try { - const shareData = await env.KV_STORE.get(`share:${shareId}`); - if (!shareData) { + const share = await getShareRecord(env, shareId); + if (!share) { return jsonResponse({ success: false, message: '分享链接不存在' }, 404); } - - const share = JSON.parse(shareData); - - // Check expiration - if (share.expiresAt && Date.now() > share.expiresAt) { + + if (shareExpired(share)) { return jsonResponse({ success: false, message: '分享链接已过期' }, 410); } - - // Check password - if (share.passwordHash) { - const body = await request.json(); - const { password } = body; - - if (!password) { - return jsonResponse({ success: false, message: '请输入密码' }, 401); - } - - const passwordHash = await hashPassword(password); - if (passwordHash !== share.passwordHash) { - return jsonResponse({ success: false, message: '密码错误' }, 401); - } + + const creds = await readShareCredentials(request); + const pwErr = await checkSharePasswordWith(share, creds.password); + if (pwErr) return pwErr; + + if (share.type && share.type !== 'file') { + return jsonResponse({ success: false, message: '该分享包含多个文件,请使用打包下载或逐个下载' }, 400); } - - // Get file from R2 - const object = await env.R2_BUCKET.get(share.filePath); + + const { object, status, range, size } = await getRangedObject(env, share.filePath, request); if (!object) { + if (status === 416) return rangeNotSatisfiable(size); return jsonResponse({ success: false, message: '文件不存在' }, 404); } - - // Update download count - share.downloadCount++; - await env.KV_STORE.put(`share:${shareId}`, JSON.stringify(share)); - - // Update global stats - const totalDownloads = parseInt(await env.KV_STORE.get('stats:totalDownloads') || '0'); - await env.KV_STORE.put('stats:totalDownloads', String(totalDownloads + 1)); - - return new Response(object.body, { - headers: { - 'Content-Type': object.httpMetadata?.contentType || getMimeType(share.fileName), - 'Content-Disposition': `attachment; filename="${encodeURIComponent(share.fileName)}"`, - 'Content-Length': object.size - } - }); + + await bumpShareStats(env, share, shareId, 'downloadCount'); + + return buildFileResponse(object, status, range, share.fileName, + 'attachment; filename="' + encodeURIComponent(share.fileName) + '"'); } catch (e) { return jsonResponse({ success: false, message: '下载失败: ' + e.message }, 500); } } -// ============================================================================ -// ADMIN HANDLERS -// ============================================================================ - -async function handleGetStats(request, env) { - const auth = await requireAdmin(request, env); - if (auth instanceof Response) return auth; - +// 游客下载分享内的单个文件(文件夹/多文件分享) +async function handleShareFileDownload(request, env, shareId) { try { - const totalShares = parseInt(await env.KV_STORE.get('stats:totalShares') || '0'); - const totalViews = parseInt(await env.KV_STORE.get('stats:totalViews') || '0'); - const totalDownloads = parseInt(await env.KV_STORE.get('stats:totalDownloads') || '0'); - - return jsonResponse({ - success: true, - totalShares, - totalViews, - totalDownloads - }); + const share = await getShareRecord(env, shareId); + if (!share) { + return jsonResponse({ success: false, message: '分享链接不存在' }, 404); + } + + if (shareExpired(share)) { + return jsonResponse({ success: false, message: '分享链接已过期' }, 410); + } + + const creds = await readShareCredentials(request); + const pwErr = await checkSharePasswordWith(share, creds.password); + if (pwErr) return pwErr; + + let rel = String(creds.path || '').replace(/^\/+|\/+$/g, ''); + const segs = rel.split('/').filter(Boolean); + if (!segs.length || segs.some(s => s === '.' || s === '..')) { + return jsonResponse({ success: false, message: '文件路径无效' }, 400); + } + + let key; + if (share.type === 'folder') { + key = share.folderPath + segs.join('/'); + } else if (share.type === 'multi') { + const wanted = segs.join('/'); + const item = (share.items || []).find(it => it.name === wanted); + if (!item) return jsonResponse({ success: false, message: '该文件不在此分享中' }, 404); + key = item.key; + } else { + return jsonResponse({ success: false, message: '单文件分享请使用下载按钮' }, 400); + } + + const { object, status, range, size } = await getRangedObject(env, key, request); + if (!object) { + if (status === 416) return rangeNotSatisfiable(size); + return jsonResponse({ success: false, message: '文件不存在' }, 404); + } + + await bumpShareStats(env, share, shareId, 'downloadCount'); + + const filename = segs[segs.length - 1]; + return buildFileResponse(object, status, range, filename, + 'attachment; filename="' + encodeURIComponent(filename) + '"'); } catch (e) { - return jsonResponse({ success: false, message: '获取统计数据失败: ' + e.message }, 500); + return jsonResponse({ success: false, message: '下载失败: ' + e.message }, 500); } } -async function handleListShares(request, env) { - const auth = await requireAdmin(request, env); - if (auth instanceof Response) return auth; +// 游客打包下载分享内容(ZIP 流式生成) +async function handleShareZipDownload(request, env, shareId) { + try { + const share = await getShareRecord(env, shareId); + if (!share) { + return jsonResponse({ success: false, message: '分享链接不存在' }, 404); + } + + if (shareExpired(share)) { + return jsonResponse({ success: false, message: '分享链接已过期' }, 410); + } + + const creds = await readShareCredentials(request); + const pwErr = await checkSharePasswordWith(share, creds.password); + if (pwErr) return pwErr; + + if (!share.type || share.type === 'file') { + return jsonResponse({ success: false, message: '单文件分享无需打包' }, 400); + } + + const entries = await listShareEntries(env, share); + if (!entries.length) { + return jsonResponse({ success: false, message: '分享内容为空' }, 400); + } + + const total = entries.reduce((s, e) => s + e.size, 0); + if (total > 3.5 * 1024 * 1024 * 1024) { + return jsonResponse({ success: false, message: '分享内容过大(超过 3.5GB),无法打包下载' }, 400); + } + + // 多文件重名去重 + const used = {}; + let dupSeq = 0; + const zipEntries = entries.map(e => { + let name = e.name; + if (used[name]) { + dupSeq++; + name = dupSeq + '_' + name; + } + used[name] = true; + return { key: e.key, name, size: e.size }; + }); + + const rootName = share.type === 'folder' ? share.folderName : 'shared-files'; + const gen = zipEntriesGenerator(env, zipEntries, rootName); + const stream = new ReadableStream({ + async pull(controller) { + const { value, done } = await gen.next(); + if (done) controller.close(); + else controller.enqueue(value); + } + }); + return new Response(stream, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="share.zip"; filename*=UTF-8\'\'' + encodeURIComponent(rootName) + '.zip' + } + }); + } catch (e) { + return jsonResponse({ success: false, message: '打包失败: ' + e.message }, 500); + } +} + +// ============================================================================ +// ADMIN HANDLERS +// ============================================================================ + +async function handleListShares(request, env) { + const auth = await requireAdmin(request, env); + if (auth instanceof Response) return auth; try { const shares = []; @@ -795,6 +1238,383 @@ async function handleDeleteShare(request, env, shareId) { } } +// 管理后台统计数据(合计自 KV 计数器) +async function handleAdminStats(request, env) { + const auth = await requireAdmin(request, env); + if (auth instanceof Response) return auth; + + try { + const [totalShares, totalViews, totalDownloads] = await Promise.all([ + env.KV_STORE.get('stats:totalShares'), + env.KV_STORE.get('stats:totalViews'), + env.KV_STORE.get('stats:totalDownloads') + ]); + return jsonResponse({ + success: true, + stats: { + totalShares: parseInt(totalShares || '0'), + totalViews: parseInt(totalViews || '0'), + totalDownloads: parseInt(totalDownloads || '0') + } + }); + } catch (e) { + return jsonResponse({ success: false, message: '获取统计失败: ' + e.message }, 500); + } +} + +async function handleBatchDeleteShares(request, env) { + const auth = await requireAdmin(request, env); + if (auth instanceof Response) return auth; + + try { + const body = await request.json().catch(() => null); + const ids = body && Array.isArray(body.shareIds) ? body.shareIds : null; + if (!ids || ids.length === 0) { + return jsonResponse({ success: false, message: 'shareIds 不能为空' }, 400); + } + if (ids.length > 500) { + return jsonResponse({ success: false, message: '单次最多删除 500 条' }, 400); + } + // shareId 由 generateId 生成(48 位字母数字),严格校验防 KV key 注入 + const valid = ids.filter(id => typeof id === 'string' && /^[A-Za-z0-9]{8,64}$/.test(id)); + if (valid.length !== ids.length) { + return jsonResponse({ success: false, message: '存在非法的分享 ID' }, 400); + } + + let deleted = 0; + const failed = []; + for (const id of valid) { + try { + // 先确认存在再删,不存在的计入 failed 而非静默成功 + const data = await env.KV_STORE.get(`share:${id}`); + if (data === null) { failed.push(id); continue; } + await env.KV_STORE.delete(`share:${id}`); + deleted++; + } catch (e) { + failed.push(id); + } + } + + if (deleted > 0) { + const totalShares = parseInt(await env.KV_STORE.get('stats:totalShares') || '0'); + await env.KV_STORE.put('stats:totalShares', String(Math.max(0, totalShares - deleted))); + } + + return jsonResponse({ + success: true, + deleted, + failed, + message: failed.length ? `已删除 ${deleted} 条,${failed.length} 条失败或不存在` : `已删除 ${deleted} 条分享链接` + }); + } catch (e) { + return jsonResponse({ success: false, message: '批量删除失败: ' + e.message }, 500); + } +} + +// 汇总某用户前缀下所有对象的大小(分页) +// ============ 文件夹打包下载(流式 ZIP,不落盘不占内存)============ +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + t[n] = c >>> 0; + } + return t; +})(); + +function crc32Init() { return 0xFFFFFFFF; } +function crc32Push(crc, buf) { + for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xFF]; + return crc; +} +function crc32Final(crc) { return (crc ^ 0xFFFFFFFF) >>> 0; } + +function dosDateTime(ms) { + const d = new Date(ms); + const year = Math.max(1980, d.getUTCFullYear()); + const date = ((year - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate(); + const time = (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | (d.getUTCSeconds() >> 1); + return { date: date & 0xffff, time: time & 0xffff }; +} + +// 已经压缩过的扩展名直接 store,其余 deflate-raw +const STORE_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'ico', 'bmp', 'mp4', 'webm', 'mkv', 'mov', 'mp3', 'wav', 'flac', 'm4a', 'ogg', 'zip', 'rar', '7z', 'gz', 'bz2', 'xz', 'pdf', 'docx', 'pptx', 'xlsx']; + +// 列出某前缀下所有文件(供 ZIP 打包) +async function listPrefixEntries(env, folderKey) { + const entries = []; + let cursor; + do { + const batch = await env.R2_BUCKET.list({ prefix: folderKey, cursor }); + for (const obj of (batch.objects || [])) { + const name = obj.key.slice(folderKey.length); + if (!name || name === '.folder' || name.endsWith('/.folder')) continue; + entries.push({ key: obj.key, name, size: obj.size, uploaded: obj.uploaded }); + } + cursor = batch.truncated ? batch.cursor : null; + } while (cursor); + return entries; +} + +async function* zipEntriesGenerator(env, entries, rootName) { + let canDeflate = false; + try { canDeflate = typeof CompressionStream !== 'undefined' && !!new CompressionStream('deflate-raw'); } catch (e) { canDeflate = false; } + + const enc = new TextEncoder(); + const central = []; + let offset = 0; + + const u16 = v => new Uint8Array([v & 0xff, (v >> 8) & 0xff]); + const u32 = v => new Uint8Array([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff]); + + for (const entry of entries) { + // 防 zip-slip:条目名按段清洗(剥掉 ..、.、空段) + const safeName = entry.name.split('/').filter(seg => seg && seg !== '.' && seg !== '..').join('/'); + if (!safeName) continue; + const nameBytes = enc.encode(rootName + '/' + safeName); + const uploadedMs = entry.uploaded ? entry.uploaded.getTime() : Date.now(); + const { date, time } = dosDateTime(uploadedMs); + const ext = safeName.split('.').pop().toLowerCase(); + const method = (canDeflate && !STORE_EXTS.includes(ext) && entry.size > 0) ? 8 : 0; + + // 先取对象再写 LFH:对象中途消失时整条跳过,不产生有头无数据的坏档 + const obj = await env.R2_BUCKET.get(entry.key); + if (!obj) continue; + + // local file header(bit3 数据描述符模式:crc/size 先置 0,流结束后补写) + const entryOffset = offset; + const lfh = new Uint8Array(30 + nameBytes.length); + const lv = new DataView(lfh.buffer); + lv.setUint32(0, 0x04034b50, true); + lv.setUint16(4, 20, true); + lv.setUint16(6, 0x0808, true); + lv.setUint16(8, method, true); + lv.setUint16(10, time, true); + lv.setUint16(12, date, true); + lv.setUint16(26, nameBytes.length, true); + lfh.set(nameBytes, 30); + yield lfh; + offset += lfh.length; + + let crc = crc32Init(); + let usize = 0, csize = 0; + let stream = obj.body.pipeThrough(new TransformStream({ + transform(chunk, ctl) { + crc = crc32Push(crc, chunk); + usize += chunk.byteLength; + ctl.enqueue(chunk); + } + })); + const counter = new TransformStream({ + transform(chunk, ctl) { csize += chunk.byteLength; ctl.enqueue(chunk); } + }); + stream = method === 8 + ? stream.pipeThrough(new CompressionStream('deflate-raw')).pipeThrough(counter) + : stream.pipeThrough(counter); + + const reader = stream.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + yield value; + offset += value.byteLength; + } + const finalCrc = crc32Final(crc); + + const dd = new Uint8Array(16); + const dv = new DataView(dd.buffer); + dv.setUint32(0, 0x08074b50, true); + dv.setUint32(4, finalCrc, true); + dv.setUint32(8, csize, true); + dv.setUint32(12, usize, true); + yield dd; + offset += dd.length; + + central.push({ nameBytes, crc: finalCrc, csize, usize, method, date, time, offset: entryOffset }); + } + + const cdStart = offset; + for (const c of central) { + const cd = new Uint8Array(46 + c.nameBytes.length); + const cv = new DataView(cd.buffer); + cv.setUint32(0, 0x02014b50, true); + cv.setUint16(4, 20, true); + cv.setUint16(6, 20, true); + cv.setUint16(8, 0x0808, true); + cv.setUint16(10, c.method, true); + cv.setUint16(12, c.time, true); + cv.setUint16(14, c.date, true); + cv.setUint32(16, c.crc, true); + cv.setUint32(20, c.csize, true); + cv.setUint32(24, c.usize, true); + cv.setUint16(28, c.nameBytes.length, true); + cv.setUint32(42, c.offset, true); + cd.set(c.nameBytes, 46); + yield cd; + offset += cd.length; + } + + const cdSize = offset - cdStart; + const eocd = new Uint8Array(22); + const ev = new DataView(eocd.buffer); + ev.setUint32(0, 0x06054b50, true); + ev.setUint16(8, central.length, true); + ev.setUint16(10, central.length, true); + ev.setUint32(12, cdSize, true); + ev.setUint32(16, cdStart, true); + yield eocd; +} + +async function* zipFolderGenerator(env, folderKey, folderName) { + const entries = await listPrefixEntries(env, folderKey); + yield* zipEntriesGenerator(env, entries, folderName); +} + +async function handleDownloadFolder(request, env, path) { + const auth = await requireAuth(request, env); + if (auth instanceof Response) return auth; + try { + const rel = (path || '').replace(/^\/+|\/+$/g, ''); + if (!rel) { + return jsonResponse({ success: false, message: '缺少文件夹路径' }, 400); + } + const folderKey = userScope(auth) + rel + '/'; + let total = 0, count = 0, cursor; + do { + const batch = await env.R2_BUCKET.list({ prefix: folderKey, cursor }); + for (const obj of (batch.objects || [])) { total += obj.size; count++; } + cursor = batch.truncated ? batch.cursor : null; + } while (cursor); + if (count === 0) { + return jsonResponse({ success: false, message: '文件夹为空' }, 400); + } + if (total > 3.5 * 1024 * 1024 * 1024) { + return jsonResponse({ success: false, message: '文件夹过大(超过 3.5GB),请分批下载' }, 400); + } + const folderName = rel.split('/').pop(); + const gen = zipFolderGenerator(env, folderKey, folderName); + const stream = new ReadableStream({ + async pull(controller) { + const { value, done } = await gen.next(); + if (done) controller.close(); + else controller.enqueue(value); + } + }); + return new Response(stream, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="folder.zip"; filename*=UTF-8\'\'' + encodeURIComponent(folderName) + '.zip' + } + }); + } catch (e) { + return jsonResponse({ success: false, message: '打包失败: ' + e.message }, 500); + } +} + +async function getUserUsedBytes(env, email) { + const prefix = (email || '_anonymous') + '/'; + let total = 0; + let cursor; + do { + const batch = await env.R2_BUCKET.list({ prefix, cursor }); + for (const obj of (batch.objects || [])) total += obj.size; + cursor = batch.truncated ? batch.cursor : null; + } while (cursor); + return total; +} + +// 读某用户存储配额(bytes),0 = 不限 +async function getUserQuotaBytes(env, email) { + const data = await env.KV_STORE.get(`user:${email}`); + if (!data) return 0; + try { + const user = JSON.parse(data); + return Number(user.quotaBytes) > 0 ? Math.round(Number(user.quotaBytes)) : 0; + } catch (e) { + return 0; + } +} + +// ============ 全盘存储上限(默认 9.5GB,对应 R2 免费额度)============ +const DEFAULT_STORAGE_CAP = 10200547328; // 9.5 GB + +async function getStorageCapBytes(env) { + const v = await env.KV_STORE.get('config:storageCap'); + const n = Number(v); + return Number.isFinite(n) && n > 0 ? Math.round(n) : DEFAULT_STORAGE_CAP; +} + +async function getGlobalUsedBytes(env) { + let total = 0; + let cursor; + do { + const batch = await env.R2_BUCKET.list({ cursor }); + for (const obj of (batch.objects || [])) total += obj.size; + cursor = batch.truncated ? batch.cursor : null; + } while (cursor); + return total; +} + +async function handleGetStorage(request, env) { + const auth = await requireAdmin(request, env); + if (auth instanceof Response) return auth; + try { + const capBytes = await getStorageCapBytes(env); + const usedBytes = await getGlobalUsedBytes(env); + return jsonResponse({ + success: true, + capBytes, + usedBytes, + usedFormatted: formatFileSize(usedBytes), + capFormatted: formatFileSize(capBytes) + }); + } catch (e) { + return jsonResponse({ success: false, message: '获取存储信息失败: ' + e.message }, 500); + } +} + +async function handleUpdateStorage(request, env) { + const auth = await requireAdmin(request, env); + if (auth instanceof Response) return auth; + try { + const body = await request.json(); + const cap = Math.round(Number(body.capBytes)); + if (!Number.isFinite(cap) || cap <= 0) { + return jsonResponse({ success: false, message: '上限必须为正数(字节)' }, 400); + } + await env.KV_STORE.put('config:storageCap', String(cap)); + return jsonResponse({ success: true, message: '存储上限已更新', capBytes: cap }); + } catch (e) { + return jsonResponse({ success: false, message: '更新失败: ' + e.message }, 500); + } +} + +// 管理员:修改某用户存储配额 +async function handleUpdateUser(request, env, emailParam) { + const auth = await requireAdmin(request, env); + if (auth instanceof Response) return auth; + + try { + const email = decodeURIComponent(emailParam); + const data = await env.KV_STORE.get(`user:${email}`); + if (!data) { + return jsonResponse({ success: false, message: '用户不存在' }, 404); + } + + const body = await request.json(); + const quota = Number(body.quotaBytes) > 0 ? Math.round(Number(body.quotaBytes)) : 0; + + const user = JSON.parse(data); + user.quotaBytes = quota; + await env.KV_STORE.put(`user:${email}`, JSON.stringify(user)); + + return jsonResponse({ success: true, message: '配额已更新', quotaBytes: quota }); + } catch (e) { + return jsonResponse({ success: false, message: '更新配额失败: ' + e.message }, 500); + } +} + async function handleListUsers(request, env) { const auth = await requireAdmin(request, env); if (auth instanceof Response) return auth; @@ -809,10 +1629,15 @@ async function handleListUsers(request, env) { const data = await env.KV_STORE.get(key.name); if (data) { const user = JSON.parse(data); + const usedBytes = await getUserUsedBytes(env, user.email); users.push({ email: user.email, role: user.role, - createdAt: user.createdAt + createdAt: user.createdAt, + quotaBytes: user.quotaBytes || 0, + usedBytes, + usedFormatted: formatFileSize(usedBytes), + quotaFormatted: user.quotaBytes > 0 ? formatFileSize(user.quotaBytes) : '不限' }); } } @@ -831,33 +1656,120 @@ async function handleCreateUser(request, env) { try { const body = await request.json(); - const { email, password } = body; - - if (!email || !password) { - return jsonResponse({ success: false, message: '请提供邮箱和密码' }, 400); + const { email, password, quotaBytes } = body; + const uname = typeof email === 'string' ? email.trim() : ''; + + if (!uname || !password) { + return jsonResponse({ success: false, message: '请提供用户名和密码' }, 400); } - + + // 用户名合法性:仅字母/数字/_ . @ -,长度 1-64(保证 R2 键安全) + if (!/^[A-Za-z0-9_.@-]{1,64}$/.test(uname)) { + return jsonResponse({ success: false, message: '用户名只能包含字母、数字、_ . @ -,长度 1-64,且不能含空格和斜杠' }, 400); + } + // Check if user already exists - const existing = await env.KV_STORE.get(`user:${email}`); + const existing = await env.KV_STORE.get(`user:${uname}`); if (existing) { - return jsonResponse({ success: false, message: '用户已存在' }, 409); + return jsonResponse({ success: false, message: '用户名已被占用' }, 409); } const userData = { - email, + email: uname, passwordHash: await hashPassword(password), role: 'user', + quotaBytes: Number(quotaBytes) > 0 ? Math.round(Number(quotaBytes)) : 0, // 0 = 不限 createdAt: Date.now() }; - await env.KV_STORE.put(`user:${email}`, JSON.stringify(userData)); + await env.KV_STORE.put(`user:${uname}`, JSON.stringify(userData)); - return jsonResponse({ success: true, message: '用户创建成功', email }); + return jsonResponse({ success: true, message: '用户创建成功', email: uname }); } catch (e) { return jsonResponse({ success: false, message: '创建用户失败: ' + e.message }, 500); } } +// ============ 自助注册(env.REGISTER_ENABLED === 'true' 开启)============ +async function handleRegister(request, env) { + try { + // 首次部署引导:站点还没有任何用户时,第一个注册的人自动成为管理员(用户名/密码自定)。 + // 站点已有用户后,注册遵循 REGISTER_ENABLED 开关。 + // + // KV 是最终一致的:首个用户写入后短时间内 list() 仍返回空,会导致连续注册出多个管理员。 + // 因此用 R2(强一致)做引导标记兜底:标记键绑定部署的 ADMIN_PASSWORD 哈希,防跨部署串扰。 + let markerKey = null; + let siteHasUsers = (await env.KV_STORE.list({ prefix: 'user:', limit: 1 })).keys.length > 0; + if (!siteHasUsers) { + markerKey = 'config:bootstrap:' + (await sha256Hex(env.ADMIN_PASSWORD || '')); + if (await env.R2_BUCKET.head(markerKey)) { + siteHasUsers = true; // 已引导过(其他写入节点尚未在 KV 可见) + } + } + if (siteHasUsers && env.REGISTER_ENABLED !== 'true') { + return jsonResponse({ success: false, message: '本站未开放注册' }, 403); + } + + const body = await request.json(); + const uname = typeof body.username === 'string' ? body.username.trim() : ''; + const { password } = body; + + if (!uname || !password) { + return jsonResponse({ success: false, message: '请提供用户名和密码' }, 400); + } + if (!/^[A-Za-z0-9_.@-]{1,64}$/.test(uname)) { + return jsonResponse({ success: false, message: '用户名只能包含字母、数字、_ . @ -,长度 1-64' }, 400); + } + if (String(password).length < 6) { + return jsonResponse({ success: false, message: '密码至少 6 位' }, 400); + } + + const existing = await env.KV_STORE.get(`user:${uname}`); + if (existing) { + return jsonResponse({ success: false, message: '用户名已被占用' }, 409); + } + + const userData = { + email: uname, + passwordHash: await hashPassword(password), + role: siteHasUsers ? 'user' : 'admin', + quotaBytes: 0, + createdAt: Date.now() + }; + await env.KV_STORE.put(`user:${uname}`, JSON.stringify(userData)); + // 首个管理员落地后立刻写 R2 强一致标记,封死后续注册的引导窗口 + if (userData.role === 'admin' && markerKey) { + await env.R2_BUCKET.put(markerKey, 'first-admin'); + } + return jsonResponse({ success: true, message: userData.role === 'admin' ? '注册成功,你已自动成为本站管理员,请登录' : '注册成功,请登录' }); + } catch (e) { + return jsonResponse({ success: false, message: '注册失败: ' + e.message }, 500); + } +} + +// 管理员重置用户密码:随机密码明文仅在本次响应返回,服务端只存哈希 +async function handleResetUserPassword(request, env, email) { + const auth = await requireAdmin(request, env); + if (auth instanceof Response) return auth; + try { + const uname = decodeURIComponent(email); + const raw = await env.KV_STORE.get(`user:${uname}`); + if (!raw) { + return jsonResponse({ success: false, message: '用户不存在' }, 404); + } + const user = JSON.parse(raw); + const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; + const arr = new Uint8Array(12); + crypto.getRandomValues(arr); + const newPw = Array.from(arr, b => charset[b % charset.length]).join(''); + user.passwordHash = await hashPassword(newPw); + await env.KV_STORE.put(`user:${uname}`, JSON.stringify(user)); + return jsonResponse({ success: true, message: '密码已重置', oneTimePassword: newPw }); + } catch (e) { + return jsonResponse({ success: false, message: '重置失败: ' + e.message }, 500); + } +} + async function handleDeleteUser(request, env, email) { const auth = await requireAdmin(request, env); if (auth instanceof Response) return auth; @@ -891,897 +1803,1005 @@ const CSS_STYLES = ` padding: 0; box-sizing: border-box; } - + :root { - --primary: #6366f1; - --primary-dark: #4f46e5; - --primary-light: #818cf8; - --secondary: #8b5cf6; - --accent: #06b6d4; - --background: #0f172a; - --surface: #1e293b; - --surface-light: #334155; - --text: #f8fafc; - --text-muted: #94a3b8; - --success: #10b981; - --warning: #f59e0b; - --error: #ef4444; - --gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #06b6d4 100%); + --primary: #818cf8; + --primary-dark: #6366f1; + --primary-light: #a5b4fc; + --secondary: #a78bfa; + --accent: #22d3ee; + --background: #0a0e1a; + --surface: rgba(255, 255, 255, 0.045); + --surface-light: rgba(255, 255, 255, 0.09); + --border: rgba(255, 255, 255, 0.09); + --border-strong: rgba(255, 255, 255, 0.16); + --text: #eef2ff; + --text-muted: #8b93b0; + --success: #34d399; + --warning: #fbbf24; + --error: #f87171; + --gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 55%, #22d3ee 100%); + --radius: 14px; + --shadow-lg: 0 18px 50px -12px rgba(0, 0, 0, 0.55); + --glow: 0 8px 28px -6px rgba(99, 102, 241, 0.5); } - + + html { scroll-behavior: smooth; } + body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--background); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + background: + radial-gradient(1100px 700px at 12% -8%, rgba(99, 102, 241, 0.20), transparent 60%), + radial-gradient(900px 650px at 108% 12%, rgba(34, 211, 238, 0.13), transparent 55%), + radial-gradient(1000px 700px at 50% 115%, rgba(139, 92, 246, 0.13), transparent 60%), + var(--background); + background-attachment: fixed; color: var(--text); min-height: 100vh; line-height: 1.6; + -webkit-font-smoothing: antialiased; } - + + ::selection { background: rgba(99, 102, 241, 0.4); } + + ::-webkit-scrollbar { width: 10px; height: 10px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 8px; + border: 2px solid transparent; + background-clip: content-box; + } + ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.24); + border: 2px solid transparent; + background-clip: content-box; + } + + a { color: var(--primary-light); text-decoration: none; } + .container { - max-width: 1200px; + max-width: 1180px; margin: 0 auto; - padding: 20px; + padding: 24px 20px 60px; } - - /* Buttons */ + + /* ========== Header ========== */ + .header { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 28px; + background: rgba(10, 14, 26, 0.72); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border); + } + + .header::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 1px; + background: linear-gradient(90deg, transparent 5%, rgba(129, 140, 248, 0.55), rgba(34, 211, 238, 0.4), transparent 95%); + pointer-events: none; + } + + .logo { + display: flex; + align-items: center; + gap: 9px; + font-size: 20px; + font-weight: 800; + letter-spacing: 0.5px; + background: var(--gradient); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + } + + .logo::before { + content: ''; + width: 13px; + height: 13px; + border-radius: 4px; + background: conic-gradient(from 210deg, #6366f1, #8b5cf6, #22d3ee, #6366f1); + box-shadow: 0 0 14px rgba(99, 102, 241, 0.8); + } + + .header-actions { display: flex; gap: 10px; } + + .user-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 15px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + font-size: 13px; + font-weight: 600; + color: var(--text); + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .user-chip::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--gradient); + box-shadow: 0 0 8px rgba(99, 102, 241, 0.8); + flex-shrink: 0; + } + + /* ========== Buttons ========== */ .btn { display: inline-flex; align-items: center; justify-content: center; - gap: 8px; - padding: 10px 20px; - border: none; - border-radius: 8px; + gap: 7px; + padding: 10px 18px; + border: 1px solid transparent; + border-radius: 11px; font-size: 14px; - font-weight: 500; + font-weight: 600; + font-family: inherit; cursor: pointer; - transition: all 0.2s ease; text-decoration: none; + white-space: nowrap; + transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease, border-color 0.16s ease, filter 0.16s ease; } - + + .btn:active { transform: translateY(0) scale(0.97); } + .btn-primary { - background: var(--gradient); - color: white; + background: linear-gradient(135deg, #6d7cff 0%, #9d5cff 55%, #2dd4ff 100%); + color: #fff; + box-shadow: 0 4px 16px -4px rgba(109, 124, 255, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.28); } - .btn-primary:hover { - transform: translateY(-2px); - box-shadow: 0 10px 20px rgba(99, 102, 241, 0.3); + transform: translateY(-1px); + box-shadow: 0 10px 26px -6px rgba(109, 124, 255, 0.65), inset 0 1px 0 rgba(255, 255, 255, 0.28); + filter: brightness(1.06); } - + .btn-secondary { - background: var(--surface-light); + background: rgba(255, 255, 255, 0.06); color: var(--text); + border-color: rgba(255, 255, 255, 0.12); + backdrop-filter: blur(8px); } - .btn-secondary:hover { - background: var(--surface); + background: rgba(255, 255, 255, 0.11); + border-color: rgba(129, 140, 248, 0.5); + color: #fff; + transform: translateY(-1px); } - + .btn-danger { - background: var(--error); - color: white; + background: rgba(248, 113, 113, 0.10); + color: var(--error); + border-color: rgba(248, 113, 113, 0.28); } - .btn-danger:hover { - background: #dc2626; + background: rgba(248, 113, 113, 0.20); + border-color: rgba(248, 113, 113, 0.5); + transform: translateY(-1px); } - + .btn-sm { - padding: 6px 12px; + padding: 5px 11px; font-size: 12px; + font-weight: 500; + border-radius: 8px; } - - /* Forms */ - .form-group { - margin-bottom: 20px; - } - + + /* ========== Forms ========== */ + .form-group { margin-bottom: 18px; } + .form-label { display: block; - margin-bottom: 8px; - font-weight: 500; + font-size: 13px; + font-weight: 600; color: var(--text-muted); + margin-bottom: 7px; + letter-spacing: 0.3px; } - - .form-input { - width: 100%; - padding: 12px 16px; - background: var(--surface); - border: 1px solid var(--surface-light); - border-radius: 8px; - color: var(--text); - font-size: 14px; - transition: all 0.2s ease; - } - - .form-input:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); - } - + + .form-input, .form-select { width: 100%; - padding: 12px 16px; - background: var(--surface); - border: 1px solid var(--surface-light); - border-radius: 8px; - color: var(--text); + padding: 12px 14px; font-size: 14px; - cursor: pointer; + font-family: inherit; + color: var(--text); + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border); + border-radius: 11px; + outline: none; + transition: border-color 0.16s ease, box-shadow 0.16s ease, background 0.16s ease; } - - /* Cards */ - .card { - background: var(--surface); - border-radius: 16px; - padding: 24px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + + .form-input::placeholder { color: rgba(139, 147, 176, 0.55); } + + .form-input:focus, + .form-select:focus { + border-color: var(--primary); + background: rgba(99, 102, 241, 0.07); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18); } - - .card-header { + + .form-select { cursor: pointer; } + .form-select option { background: #141a2e; color: var(--text); } + + /* ========== Login ========== */ + .login-container { + min-height: 100vh; display: flex; align-items: center; - justify-content: space-between; - margin-bottom: 20px; + justify-content: center; + padding: 24px; + position: relative; + overflow: hidden; } - - .card-title { - font-size: 18px; - font-weight: 600; + + .login-container::before, + .login-container::after { + content: ''; + position: absolute; + border-radius: 50%; + filter: blur(90px); + pointer-events: none; } - - /* Tables */ - .table-container { - overflow-x: auto; + + .login-container::before { + width: 480px; + height: 480px; + background: rgba(99, 102, 241, 0.26); + top: -140px; + left: -120px; + animation: float 9s ease-in-out infinite alternate; } - - table { - width: 100%; - border-collapse: collapse; + + .login-container::after { + width: 420px; + height: 420px; + background: rgba(34, 211, 238, 0.18); + bottom: -130px; + right: -110px; + animation: float 11s ease-in-out infinite alternate-reverse; } - - th, td { - padding: 12px 16px; - text-align: left; - border-bottom: 1px solid var(--surface-light); + + .login-card { + position: relative; + z-index: 1; + width: min(410px, 100%); + background: rgba(17, 23, 42, 0.78); + border: 1px solid var(--border-strong); + border-radius: 22px; + padding: 42px 36px; + box-shadow: var(--shadow-lg); + backdrop-filter: blur(22px); + -webkit-backdrop-filter: blur(22px); + animation: fadeUp 0.5s ease; } - - th { - font-weight: 600; - color: var(--text-muted); - font-size: 12px; - text-transform: uppercase; + + .login-header { text-align: center; margin-bottom: 30px; } + + .login-logo { + font-size: 32px; + font-weight: 800; letter-spacing: 0.5px; + background: var(--gradient); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; } - - tr:hover { - background: var(--surface-light); + + .login-subtitle { + color: var(--text-muted); + font-size: 14px; + margin-top: 6px; } - - /* Modal */ - .modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.7); + + /* ========== Breadcrumb / Toolbar / Card ========== */ + .breadcrumb { display: flex; align-items: center; - justify-content: center; - z-index: 1000; - opacity: 0; - visibility: hidden; - transition: all 0.3s ease; + flex-wrap: wrap; + gap: 4px; + padding: 9px 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + border-radius: 11px; + font-size: 13.5px; + backdrop-filter: blur(10px); } - - .modal-overlay.active { - opacity: 1; - visibility: visible; + + .breadcrumb-item { color: var(--text-muted); transition: color 0.15s; cursor: pointer; } + a.breadcrumb-item:hover { color: var(--primary-light); } + .breadcrumb-item.active { color: var(--text); font-weight: 600; } + .breadcrumb-separator { color: rgba(139, 147, 176, 0.4); margin: 0 4px; user-select: none; } + + .page-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 20px; } - - .modal { - background: var(--surface); - border-radius: 16px; - padding: 24px; - width: 90%; - max-width: 500px; - transform: scale(0.9); - transition: all 0.3s ease; - max-height: 90vh; - overflow-y: auto; + + .toolbar { + display: flex; + gap: 10px; + flex-wrap: wrap; } - - .modal-overlay.active .modal { - transform: scale(1); + + .file-panel { min-height: 220px; } + + .card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + animation: fadeUp 0.35s ease; } - - .modal-header { + + .card-header { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 20px; + gap: 12px; + margin-bottom: 16px; } - - .modal-title { - font-size: 20px; - font-weight: 600; + + .card-title { font-size: 16px; font-weight: 700; } + + /* ========== File Grid ========== */ + .file-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 14px; } - - .modal-close { - background: none; - border: none; - color: var(--text-muted); - font-size: 24px; + + .file-item { + display: flex; + flex-direction: column; + gap: 11px; + padding: 18px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + border-radius: 16px; cursor: pointer; - padding: 0; - line-height: 1; + animation: fadeUp 0.3s ease both; + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; } - - .modal-close:hover { - color: var(--text); + + .file-item:hover { + transform: translateY(-4px); + border-color: rgba(129, 140, 248, 0.55); + background: rgba(255, 255, 255, 0.07); + box-shadow: 0 18px 40px -16px rgba(99, 102, 241, 0.45); } - - /* Preview Modal - Full Screen */ - .preview-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.95); + + .file-icon { + width: 52px; + height: 52px; display: flex; - flex-direction: column; - z-index: 2000; - opacity: 0; - visibility: hidden; - transition: all 0.3s ease; + align-items: center; + justify-content: center; + font-size: 26px; + border-radius: 15px; + background: linear-gradient(135deg, rgba(109, 124, 255, 0.22), rgba(45, 212, 255, 0.12)); + border: 1px solid rgba(129, 140, 248, 0.3); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12); } - - .preview-overlay.active { - opacity: 1; - visibility: visible; + + .file-name { + font-size: 14.5px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - - .preview-header { + + .file-meta { + font-size: 12px; + color: var(--text-muted); display: flex; align-items: center; - justify-content: space-between; - padding: 16px 24px; - background: var(--surface); - border-bottom: 1px solid var(--surface-light); + gap: 6px; } - - .preview-filename { - font-weight: 600; - color: var(--text); + + .file-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: auto; + padding-top: 6px; } - - .preview-actions { + + /* ========== Empty State ========== */ + .empty-state { display: flex; - gap: 12px; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 70px 20px; + color: var(--text-muted); } - - .preview-content { - flex: 1; - overflow: auto; + + .empty-icon { + width: 88px; + height: 88px; display: flex; align-items: center; justify-content: center; - padding: 20px; + font-size: 42px; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, rgba(99, 102, 241, 0.25), rgba(34, 211, 238, 0.10)); + border: 1px solid var(--border-strong); } - - .preview-image { - max-width: 100%; - max-height: 100%; - object-fit: contain; + + /* ========== Badges ========== */ + .badge { + display: inline-flex; + align-items: center; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.3px; } - - .preview-text { - width: 100%; - height: 100%; + .badge-info { background: rgba(34, 211, 238, 0.12); color: var(--accent); border: 1px solid rgba(34, 211, 238, 0.25); } + .badge-success { background: rgba(52, 211, 153, 0.12); color: var(--success); border: 1px solid rgba(52, 211, 153, 0.25); } + .badge-warning { background: rgba(251, 191, 36, 0.12); color: var(--warning); border: 1px solid rgba(251, 191, 36, 0.25); } + .badge-error { background: rgba(248, 113, 113, 0.12); color: var(--error); border: 1px solid rgba(248, 113, 113, 0.25); } + + /* ========== Tabs (admin) ========== */ + .tabs { + display: flex; + gap: 6px; + padding: 5px; background: var(--surface); - border-radius: 8px; - padding: 20px; - overflow: auto; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 14px; - line-height: 1.6; - white-space: pre-wrap; - word-wrap: break-word; + border: 1px solid var(--border); + border-radius: 999px; + width: fit-content; + margin-bottom: 22px; + backdrop-filter: blur(10px); } - - .preview-pdf { - width: 100%; - height: 100%; + + .tab { + padding: 9px 20px; border: none; - border-radius: 8px; + background: transparent; + color: var(--text-muted); + font-size: 14px; + font-weight: 600; + font-family: inherit; + border-radius: 999px; + cursor: pointer; + transition: all 0.18s ease; } - - .preview-video, .preview-audio { - max-width: 100%; - max-height: 100%; + + .tab:hover { color: var(--text); } + + .tab.active { + background: var(--gradient); + color: #fff; + box-shadow: 0 4px 14px -4px rgba(99, 102, 241, 0.5); } - - .preview-markdown { - width: 100%; - max-width: 900px; - height: 100%; - background: var(--surface); - border-radius: 8px; - padding: 40px; - overflow: auto; - line-height: 1.8; - } - - .preview-markdown h1, .preview-markdown h2, .preview-markdown h3 { - margin-top: 24px; - margin-bottom: 16px; - color: var(--text); - } - - .preview-markdown p { - margin-bottom: 16px; + + .tab-content { display: none; } + .tab-content.active { display: block; animation: fadeUp 0.3s ease; } + + /* ========== Stats ========== */ + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; } - - .preview-markdown code { - background: var(--background); - padding: 2px 6px; - border-radius: 4px; - font-family: 'Monaco', 'Menlo', monospace; + + .stat-card { + position: relative; + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 18px; + padding: 26px 24px; + backdrop-filter: blur(14px); + transition: transform 0.18s ease, border-color 0.18s ease; } - - .preview-markdown pre { - background: var(--background); - padding: 16px; - border-radius: 8px; - overflow-x: auto; - margin-bottom: 16px; + + .stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--gradient); + opacity: 0.8; } - - .preview-markdown pre code { - background: none; - padding: 0; + + .stat-card:hover { transform: translateY(-3px); border-color: rgba(99, 102, 241, 0.4); } + + .stat-value { + font-size: 34px; + font-weight: 800; + line-height: 1.2; + background: var(--gradient); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; } - - .preview-markdown blockquote { - border-left: 4px solid var(--primary); - padding-left: 16px; - margin: 16px 0; + + .stat-label { color: var(--text-muted); + font-size: 13px; + margin-top: 6px; } - - .preview-markdown ul, .preview-markdown ol { - margin-bottom: 16px; - padding-left: 24px; - } - - .preview-markdown li { - margin-bottom: 8px; - } - - .preview-markdown a { - color: var(--primary); - } - - .preview-markdown img { - max-width: 100%; - border-radius: 8px; + + /* ========== Table ========== */ + .table-container { + overflow-x: auto; + border-radius: 12px; + border: 1px solid var(--border); } - - .preview-markdown table { + + .table-container table { width: 100%; border-collapse: collapse; - margin-bottom: 16px; - } - - .preview-markdown th, .preview-markdown td { - border: 1px solid var(--surface-light); - padding: 8px 12px; - } - - .preview-office { - width: 100%; - height: 100%; - background: white; - border-radius: 8px; + font-size: 14px; } - - .preview-loading { - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; + + .table-container th { + text-align: left; + padding: 12px 16px; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; color: var(--text-muted); + background: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid var(--border); + white-space: nowrap; } - - .preview-error { - text-align: center; - color: var(--error); + + .table-container td { + padding: 13px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); } - - /* Toast */ - .toast-container { + + .table-container tbody tr { transition: background 0.15s; } + .table-container tbody tr:hover { background: rgba(99, 102, 241, 0.06); } + .table-container tbody tr:last-child td { border-bottom: none; } + + /* ========== Modals ========== */ + .modal-overlay { position: fixed; - top: 20px; - right: 20px; - z-index: 3000; - display: flex; - flex-direction: column; - gap: 10px; - } - - .toast { - padding: 16px 20px; - border-radius: 8px; - color: white; - font-weight: 500; - animation: slideIn 0.3s ease; - display: flex; + inset: 0; + z-index: 900; + display: none; align-items: center; - gap: 10px; - min-width: 300px; - } - - .toast-success { - background: var(--success); - } - - .toast-error { - background: var(--error); - } - - .toast-info { - background: var(--primary); + justify-content: center; + padding: 20px; + background: rgba(5, 8, 18, 0.65); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); } - - @keyframes slideIn { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } + + .modal-overlay.active { display: flex; animation: fadeIn 0.18s ease; } + + .modal { + width: min(440px, 100%); + background: rgba(20, 26, 46, 0.94); + border: 1px solid var(--border-strong); + border-radius: 18px; + padding: 26px; + box-shadow: var(--shadow-lg); + animation: popIn 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); } - - /* Header */ - .header { - background: var(--surface); - padding: 16px 24px; + + .modal-header { display: flex; align-items: center; justify-content: space-between; - border-bottom: 1px solid var(--surface-light); - } - - .logo { - font-size: 24px; - font-weight: 700; - background: var(--gradient); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - } - - .header-actions { - display: flex; - gap: 12px; - } - - /* Breadcrumb */ - .breadcrumb { - display: flex; - align-items: center; - gap: 8px; - padding: 16px 0; - flex-wrap: wrap; - } - - .breadcrumb-item { - color: var(--text-muted); - text-decoration: none; - transition: color 0.2s; - } - - .breadcrumb-item:hover { - color: var(--primary); - } - - .breadcrumb-item.active { - color: var(--text); - } - - .breadcrumb-separator { - color: var(--text-muted); - } - - /* File List */ - .file-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 16px; - } - - .file-item { - background: var(--surface); - border-radius: 12px; - padding: 16px; - cursor: pointer; - transition: all 0.2s ease; - border: 1px solid transparent; + margin-bottom: 18px; } - - .file-item:hover { - border-color: var(--primary); - transform: translateY(-2px); - } - - .file-icon { - font-size: 48px; - margin-bottom: 12px; - text-align: center; - } - - .file-name { - font-weight: 500; - text-align: center; - word-break: break-all; - margin-bottom: 4px; - } - - .file-meta { - font-size: 12px; - color: var(--text-muted); - text-align: center; - } - - .file-actions { - display: flex; - gap: 8px; - margin-top: 12px; - justify-content: center; - flex-wrap: wrap; - } - - /* Stats Cards */ - .stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 20px; - margin-bottom: 30px; - } - - .stat-card { - background: var(--surface); - border-radius: 16px; - padding: 24px; - text-align: center; - } - - .stat-value { - font-size: 36px; - font-weight: 700; - background: var(--gradient); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - } - - .stat-label { - color: var(--text-muted); - font-size: 14px; - margin-top: 8px; - } - - /* Tabs */ - .tabs { - display: flex; - gap: 4px; + + .modal-title { font-size: 17px; font-weight: 700; } + + .modal-close { background: var(--surface); - padding: 4px; - border-radius: 12px; - margin-bottom: 24px; - } - - .tab { - flex: 1; - padding: 12px 20px; - border: none; - background: transparent; + border: 1px solid var(--border); color: var(--text-muted); - font-size: 14px; - font-weight: 500; + width: 30px; + height: 30px; + border-radius: 9px; + font-size: 18px; + line-height: 1; cursor: pointer; - border-radius: 8px; - transition: all 0.2s ease; + transition: all 0.15s; } - - .tab.active { - background: var(--primary); - color: white; - } - - .tab:hover:not(.active) { + + .modal-close:hover { color: var(--text); + border-color: var(--border-strong); + background: var(--surface-light); } - - .tab-content { + + /* ========== Preview ========== */ + .preview-overlay { + position: fixed; + inset: 0; + z-index: 1000; display: none; + flex-direction: column; + background: rgba(6, 9, 20, 0.82); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); } - - .tab-content.active { - display: block; - } - - /* Badge */ - .badge { - display: inline-block; - padding: 4px 8px; - border-radius: 4px; - font-size: 12px; - font-weight: 500; - } - - .badge-success { - background: rgba(16, 185, 129, 0.2); - color: var(--success); - } - - .badge-warning { - background: rgba(245, 158, 11, 0.2); - color: var(--warning); - } - - .badge-error { - background: rgba(239, 68, 68, 0.2); - color: var(--error); + + .preview-overlay.active { display: flex; animation: fadeIn 0.2s ease; } + + .preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px 22px; + border-bottom: 1px solid var(--border); + background: rgba(10, 14, 26, 0.6); + backdrop-filter: blur(14px); } - - .badge-info { - background: rgba(99, 102, 241, 0.2); - color: var(--primary); + + .preview-filename { + font-weight: 600; + font-size: 15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - - /* Login Page */ - .login-container { - min-height: 100vh; + + .preview-actions { display: flex; gap: 10px; flex-shrink: 0; } + + .preview-content { + flex: 1; display: flex; align-items: center; justify-content: center; - background: var(--background); - padding: 20px; + padding: 24px; + overflow: auto; } - - .login-card { - background: var(--surface); - border-radius: 24px; - padding: 40px; + + .preview-image { + max-width: 100%; + max-height: 82vh; + border-radius: 12px; + box-shadow: var(--shadow-lg); + } + + .preview-pdf { width: 100%; - max-width: 420px; - box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25); + height: 100%; + border: none; + border-radius: 12px; + background: #fff; } - - .login-header { - text-align: center; - margin-bottom: 32px; + + .preview-text { + width: 100%; + max-width: 900px; + max-height: 82vh; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; + font-size: 13px; + line-height: 1.7; + background: rgba(5, 8, 18, 0.6); + border: 1px solid var(--border); + border-radius: 14px; + padding: 22px 24px; } - - .login-logo { - font-size: 32px; - font-weight: 700; - background: var(--gradient); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - margin-bottom: 8px; + + .preview-video { + max-width: 100%; + max-height: 82vh; + border-radius: 12px; + background: #000; + box-shadow: var(--shadow-lg); } - - .login-subtitle { - color: var(--text-muted); + + .preview-audio { width: min(560px, 92%); } + + .preview-office { + width: 100%; + max-width: 900px; + max-height: 82vh; + overflow: auto; } - - .login-tabs { + + .preview-loading { display: flex; - gap: 4px; - background: var(--background); - padding: 4px; - border-radius: 12px; - margin-bottom: 24px; - } - - .login-tab { - flex: 1; - padding: 12px; - border: none; - background: transparent; + flex-direction: column; + align-items: center; + gap: 14px; color: var(--text-muted); - font-size: 14px; - font-weight: 500; - cursor: pointer; - border-radius: 8px; - transition: all 0.2s ease; } - - .login-tab.active { - background: var(--primary); - color: white; - } - - /* Share Page */ - .share-container { - min-height: 100vh; + + .preview-error { display: flex; + flex-direction: column; align-items: center; - justify-content: center; - background: var(--background); - padding: 20px; - } - - .share-card { - background: var(--surface); - border-radius: 24px; + gap: 12px; + color: var(--error); padding: 40px; + } + + .preview-markdown { + max-width: 860px; width: 100%; - max-width: 480px; - text-align: center; + background: rgba(15, 20, 38, 0.85); + border: 1px solid var(--border); + border-radius: 16px; + padding: 30px 32px; + overflow: auto; + line-height: 1.75; } - - .share-icon { - font-size: 64px; - margin-bottom: 20px; + + .preview-markdown h1, .preview-markdown h2 { + border-bottom: 1px solid var(--border); + padding-bottom: 8px; + margin: 22px 0 12px; + } + .preview-markdown h1 { font-size: 24px; } + .preview-markdown h2 { font-size: 20px; } + .preview-markdown h3 { font-size: 17px; margin: 18px 0 10px; } + .preview-markdown p { margin: 10px 0; } + + .preview-markdown code { + background: rgba(255, 255, 255, 0.08); + padding: 2px 7px; + border-radius: 6px; + font-size: 13px; + font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; } - - .share-filename { - font-size: 20px; - font-weight: 600; - margin-bottom: 8px; - word-break: break-all; + + .preview-markdown pre { + background: rgba(0, 0, 0, 0.35); + padding: 16px; + border-radius: 12px; + overflow: auto; + margin: 12px 0; } - - .share-filesize { + .preview-markdown pre code { background: transparent; padding: 0; } + + .preview-markdown blockquote { + border-left: 3px solid var(--primary); + padding-left: 14px; color: var(--text-muted); - margin-bottom: 24px; + margin: 12px 0; } - - .share-expired { - color: var(--error); - font-size: 18px; + + .preview-markdown img { max-width: 100%; border-radius: 10px; } + .preview-markdown a { color: var(--accent); } + .preview-markdown ul, .preview-markdown ol { padding-left: 24px; margin: 10px 0; } + + .preview-markdown table { border-collapse: collapse; margin: 12px 0; } + .preview-markdown th, .preview-markdown td { + border: 1px solid var(--border-strong); + padding: 8px 12px; } - - /* Empty State */ - .empty-state { - text-align: center; - padding: 60px 20px; - color: var(--text-muted); + + /* ========== Toast ========== */ + .toast-container { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 1200; + display: flex; + flex-direction: column; + gap: 10px; } - - .empty-icon { - font-size: 64px; - margin-bottom: 16px; - opacity: 0.5; + + .toast { + padding: 12px 18px; + border-radius: 12px; + font-size: 14px; + font-weight: 500; + max-width: 340px; + background: rgba(20, 26, 46, 0.94); + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(12px); + animation: toastIn 0.28s cubic-bezier(0.21, 1.02, 0.73, 1); } - - /* Responsive */ - @media (max-width: 768px) { - .header { - flex-direction: column; - gap: 16px; - } - - .header-actions { - width: 100%; - justify-content: center; - } - - .file-grid { - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - } - - .stats-grid { - grid-template-columns: 1fr; - } - - .tabs { - flex-direction: column; - } - - .preview-header { - flex-direction: column; - gap: 12px; - } + + .toast-success { border-left: 3px solid var(--success); } + .toast-error { border-left: 3px solid var(--error); } + .toast-info { border-left: 3px solid var(--accent); } + + /* ========== Loading / Spinner ========== */ + .loading-overlay { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(6, 9, 20, 0.55); + backdrop-filter: blur(4px); } - - /* Loading Spinner */ + .spinner { - width: 40px; - height: 40px; - border: 3px solid var(--surface-light); - border-top-color: var(--primary); + width: 34px; + height: 34px; border-radius: 50%; - animation: spin 1s linear infinite; - } - - @keyframes spin { - to { - transform: rotate(360deg); - } + border: 3px solid rgba(255, 255, 255, 0.12); + border-top-color: var(--primary-light); + animation: spin 0.8s linear infinite; } - - .loading-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(15, 23, 42, 0.8); + + /* ========== Share Page ========== */ + .share-container { + min-height: 100vh; display: flex; align-items: center; justify-content: center; - z-index: 3000; + padding: 24px; } - - /* Context Menu */ + + .share-card { + width: min(460px, 100%); + background: rgba(17, 23, 42, 0.8); + border: 1px solid var(--border-strong); + border-radius: 20px; + padding: 36px 32px; + text-align: center; + box-shadow: var(--shadow-lg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + animation: fadeUp 0.4s ease; + } + + .share-icon { font-size: 52px; margin-bottom: 14px; } + .share-filename { font-size: 18px; font-weight: 700; word-break: break-all; margin-bottom: 6px; } + .share-filesize { color: var(--text-muted); font-size: 13px; margin-bottom: 22px; } + .share-expired { color: var(--error); font-size: 15px; padding: 30px 10px; } + + /* ========== Context Menu ========== */ .context-menu { position: fixed; - background: var(--surface); - border-radius: 8px; - padding: 8px 0; + z-index: 1300; min-width: 160px; - box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); - z-index: 1500; - display: none; - } - - .context-menu.active { - display: block; + background: rgba(20, 26, 46, 0.96); + border: 1px solid var(--border-strong); + border-radius: 12px; + padding: 6px; + box-shadow: var(--shadow-lg); + backdrop-filter: blur(14px); } - + .context-menu-item { - padding: 10px 16px; + padding: 9px 14px; + border-radius: 8px; + font-size: 13px; cursor: pointer; - display: flex; - align-items: center; - gap: 10px; - transition: background 0.2s; - } - - .context-menu-item:hover { - background: var(--surface-light); - } - - .context-menu-item.danger { - color: var(--error); - } - - /* Toolbar */ - .toolbar { - display: flex; - gap: 12px; - margin-bottom: 20px; - flex-wrap: wrap; + transition: background 0.13s; } - - /* Upload Area */ + + .context-menu-item:hover { background: rgba(99, 102, 241, 0.16); } + + /* ========== Upload Area ========== */ .upload-area { - border: 2px dashed var(--surface-light); - border-radius: 12px; - padding: 40px; + border: 2px dashed rgba(255, 255, 255, 0.18); + border-radius: var(--radius); + padding: 42px 20px; text-align: center; - cursor: pointer; - transition: all 0.2s ease; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.02); + transition: all 0.18s ease; } - - .upload-area:hover, .upload-area.dragover { + + .upload-area:hover, + .upload-area.dragover { border-color: var(--primary); - background: rgba(99, 102, 241, 0.1); + background: rgba(99, 102, 241, 0.07); + color: var(--text); } - - .upload-area input { - display: none; + + .upload-area input { display: none; } + + /* ========== Share Source Picker ========== */ + .share-src-tabs { display: flex; gap: 8px; margin-bottom: 14px; } + .share-src-tab { + flex: 1; padding: 9px 0; border-radius: 10px; cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.10); + background: rgba(255, 255, 255, 0.03); color: var(--text-muted); + font-size: 13px; transition: all 0.18s ease; + } + .share-src-tab:hover { color: var(--text); border-color: rgba(255, 255, 255, 0.22); } + .share-src-tab.active { + color: #fff; border-color: rgba(139, 124, 255, 0.55); + background: linear-gradient(135deg, rgba(109, 124, 255, 0.25), rgba(157, 92, 255, 0.25)); + } + .share-browse-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } + .share-browse-path { + flex: 1; font-size: 12px; color: var(--text-muted); font-family: monospace; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .share-browse-list { + max-height: 220px; overflow-y: auto; + border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 10px; + background: rgba(0, 0, 0, 0.18); + } + .share-browse-item { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; + cursor: pointer; font-size: 13px; border-bottom: 1px solid rgba(255, 255, 255, 0.04); + } + .share-browse-item:last-child { border-bottom: none; } + .share-browse-item:hover { background: rgba(99, 102, 241, 0.12); } + .share-browse-item.selected { background: rgba(99, 102, 241, 0.22); } + .sbi-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); } + .sbi-size { font-size: 11px; color: var(--text-muted); flex-shrink: 0; } + .share-browse-loading { padding: 18px; text-align: center; color: var(--text-muted); font-size: 12px; } + .share-picked { + display: none; align-items: center; gap: 8px; margin-top: 8px; padding: 8px 12px; + border-radius: 10px; background: rgba(45, 212, 255, 0.08); + border: 1px solid rgba(45, 212, 255, 0.25); color: var(--text); font-size: 13px; + } + .share-upload-dest { margin-top: 8px; font-size: 12px; color: var(--text-muted); } + .share-upload-dest b { color: var(--text); } + .share-modal-upload .upload-area { padding: 24px 16px; cursor: pointer; } + + /* ========== Keyframes ========== */ + @keyframes spin { to { transform: rotate(360deg); } } + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + @keyframes fadeUp { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } + } + @keyframes popIn { + from { opacity: 0; transform: scale(0.92) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } + } + @keyframes toastIn { + from { opacity: 0; transform: translateX(30px); } + to { opacity: 1; transform: translateX(0); } + } + @keyframes float { + from { transform: translate(0, 0); } + to { transform: translate(24px, 32px); } + } + + /* ========== Responsive ========== */ + @media (max-width: 720px) { + .header { padding: 12px 16px; } + .container { padding: 16px 14px 50px; } + .file-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; } + .file-actions .btn-sm { padding: 4px 8px; font-size: 11px; } + .toolbar .btn { flex: 1; } + .tabs { width: 100%; justify-content: space-around; } + .tab { padding: 8px 12px; font-size: 13px; } + .login-card { padding: 34px 24px; } } `; @@ -1795,7 +2815,7 @@ const LOGIN_PAGE = ` 登录 - EdgeStash ${CSS_STYLES} - +