Compare commits
10 Commits
0a834cb9f6
...
ui-redesig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7ff557942 | ||
|
|
5d0611de60 | ||
|
|
1bf9c072c8 | ||
|
|
6764f4c53b | ||
|
|
df12a6ac72 | ||
|
|
4f6ccd2978 | ||
|
|
51124bbb2e | ||
|
|
1b702b1b93 | ||
|
|
52304d10e0 | ||
|
|
8953277de3 |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
server/node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
700
PROJECT_ARCHITECTURE.md
Normal file
700
PROJECT_ARCHITECTURE.md
Normal file
@@ -0,0 +1,700 @@
|
||||
# 涩花塘磁力助手 - 项目架构与部署说明
|
||||
|
||||
> 更新时间:2026-03-14
|
||||
> 说明:本文档描述当前项目的整体架构、核心数据流、缓存策略、云同步设计、线上部署信息(**不包含任何敏感凭据**),以及当前已知问题与优化方向。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概览
|
||||
|
||||
本项目由两部分组成:
|
||||
|
||||
1. **Chrome 扩展端**
|
||||
负责在涩花塘论坛页面中:
|
||||
- 识别列表页 / 帖子页
|
||||
- 批量抓取帖子与磁力链接
|
||||
- 本地缓存帖子、范围快照、页快照、磁力结果
|
||||
- 展示搜索结果、缓存总览、收藏夹、云同步入口
|
||||
|
||||
2. **云端共享缓存服务**
|
||||
负责:
|
||||
- 共享线程缓存(shared thread cache)
|
||||
- 共享范围缓存(shared coverage cache)
|
||||
- 共享页缓存(shared page cache)
|
||||
- 覆盖规划(coverage planning)
|
||||
- 私有保险柜(vault)
|
||||
- 账号注册 / 登录 / 鉴权
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
### 扩展端根目录
|
||||
|
||||
- `manifest.json`:Chrome 扩展清单(MV3)
|
||||
- `content.js`:内容脚本,挂载 UI、执行抓取、合并缓存、结果展示
|
||||
- `background.js`:Service Worker,负责 IndexedDB、本地状态、云接口、跨页请求
|
||||
- `popup.html` / `popup.js`:扩展独立云同步中心 + 快捷复制入口
|
||||
|
||||
### 服务端目录
|
||||
|
||||
- `server/package.json`:服务端依赖与脚本
|
||||
- `server/src/index.js`:Fastify 入口
|
||||
- `server/src/config.js`:环境变量配置
|
||||
- `server/src/db.js`:MySQL 连接池
|
||||
- `server/src/redis.js`:Redis 连接与 JSON 缓存
|
||||
- `server/src/crypto.js`:AES-GCM 加密与哈希
|
||||
- `server/src/auth.js`:认证辅助逻辑
|
||||
- `server/src/routes/auth.js`:注册 / 登录 / me / logout
|
||||
- `server/src/routes/vault.js`:私有保险柜 push / pull
|
||||
- `server/src/routes/shared-cache.js`:共享缓存查找 / 写入 / coverage planning
|
||||
- `server/sql/001_init.sql`:数据库初始化表结构
|
||||
|
||||
---
|
||||
|
||||
## 3. 扩展端架构
|
||||
|
||||
### 3.1 页面层(content.js)
|
||||
|
||||
`content.js` 负责:
|
||||
|
||||
- 判断当前页面是:
|
||||
- 列表页(forumdisplay / forum-x-y.html / normalthread tbody)
|
||||
- 帖子页(thread-xxx / tid=xxx)
|
||||
- 创建浮动面板 UI
|
||||
- 管理:
|
||||
- 关键词搜索
|
||||
- 页码范围
|
||||
- 速度模式
|
||||
- 结果列表
|
||||
- 收藏页
|
||||
- 缓存页
|
||||
- 云同步页入口
|
||||
- 调用后台:
|
||||
- 读取 / 保存本地缓存
|
||||
- 读取 / 保存进度状态
|
||||
- 触发云同步相关动作
|
||||
|
||||
### 3.2 后台层(background.js)
|
||||
|
||||
`background.js` 是当前项目的核心调度层,负责:
|
||||
|
||||
- IndexedDB 缓存存取
|
||||
- 云缓存接口调用
|
||||
- 共享缓存 planning 调用
|
||||
- 上传抑制(hash 去重 + TTL 节流)
|
||||
- 本地历史缓存回填到云端
|
||||
- 私有保险柜加解密
|
||||
- 云账号登录状态维护
|
||||
|
||||
### 3.3 本地存储层
|
||||
|
||||
当前本地存储分三类:
|
||||
|
||||
1. **IndexedDB(结构化缓存)**
|
||||
- `threads`
|
||||
- `coverages`
|
||||
- `pageCoverages`
|
||||
|
||||
2. **chrome.storage.local(扩展私有存储)**
|
||||
- 收藏夹
|
||||
- 搜索历史
|
||||
- 云同步状态
|
||||
- 上传元数据去重表
|
||||
- 进度状态
|
||||
|
||||
3. **运行时内存态**
|
||||
- 当前抓取状态
|
||||
- session backup 临时状态
|
||||
|
||||
---
|
||||
|
||||
## 4. 服务端架构
|
||||
|
||||
### 4.1 服务端职责
|
||||
|
||||
云服务现在不仅是“存数据”,还承担:
|
||||
|
||||
- 共享缓存存储
|
||||
- 去重写入
|
||||
- coverage planning
|
||||
- Redis 缓存 planning 结果
|
||||
- 账号认证
|
||||
- 私有保险柜同步
|
||||
|
||||
### 4.2 路由职责
|
||||
|
||||
#### 认证
|
||||
- `POST /api/auth/register`
|
||||
- `POST /api/auth/login`
|
||||
- `GET /api/auth/me`
|
||||
- `POST /api/auth/logout`
|
||||
|
||||
#### 私有保险柜
|
||||
- `POST /api/vault/push`
|
||||
- `POST /api/vault/pull`
|
||||
|
||||
#### 共享缓存
|
||||
- `POST /api/shared-cache/threads/lookup`
|
||||
- `POST /api/shared-cache/threads/upsert`
|
||||
- `POST /api/shared-cache/coverages/lookup`
|
||||
- `POST /api/shared-cache/coverages/upsert`
|
||||
- `POST /api/shared-cache/pages/lookup`
|
||||
- `POST /api/shared-cache/pages/upsert`
|
||||
- `POST /api/shared-cache/coverages/plan`
|
||||
|
||||
#### 健康检查
|
||||
- `GET /health`
|
||||
- `GET /ready`
|
||||
|
||||
---
|
||||
|
||||
## 5. 本地缓存模型
|
||||
|
||||
### 5.1 `threads`
|
||||
存储唯一帖子级信息:
|
||||
|
||||
- `forumKey`
|
||||
- `threadKey`
|
||||
- `url`
|
||||
- `title`
|
||||
- `lastSeenAt`
|
||||
- `magnets`
|
||||
- `lastMagnetSyncAt`
|
||||
|
||||
特点:
|
||||
- 同一板块 + 同一帖子只保留一份
|
||||
- 线程磁链缓存优先从这里命中
|
||||
|
||||
### 5.2 `coverages`
|
||||
存储范围级快照:
|
||||
|
||||
- `forumKey`
|
||||
- `startPage`
|
||||
- `endPage`
|
||||
- `strategy`
|
||||
- `frontRefreshPages`
|
||||
- `threadKeys`
|
||||
|
||||
特点:
|
||||
- 表示某个页范围对应的帖子集合
|
||||
- 用于 exact coverage / shifted coverage / 历史 coverage 碎片复用
|
||||
|
||||
### 5.3 `pageCoverages`
|
||||
存储页级快照:
|
||||
|
||||
- `forumKey`
|
||||
- `page`
|
||||
- `threadKeys`
|
||||
|
||||
特点:
|
||||
- 用来组装连续页块
|
||||
- 当前大范围命中优化的重要基础
|
||||
|
||||
---
|
||||
|
||||
## 6. 云端缓存模型
|
||||
|
||||
### 6.1 共享线程缓存 `shared_thread_cache`
|
||||
- 唯一键:`(forum_key, thread_key)`
|
||||
- 数据库存储加密:AES-GCM
|
||||
- 用于跨用户复用帖子级磁链结果
|
||||
|
||||
### 6.2 共享范围缓存 `shared_coverage_cache`
|
||||
- 唯一键:`(forum_key, start_page, end_page, strategy)`
|
||||
- 用于整段范围的覆盖规划
|
||||
|
||||
### 6.3 共享页缓存 `shared_page_cache`
|
||||
- 唯一键:`(forum_key, page)`
|
||||
- 用于连续页块拼装
|
||||
|
||||
### 6.4 私有保险柜 `vault_items`
|
||||
- 用户级密文存储
|
||||
- 当前用于:
|
||||
- 收藏夹
|
||||
- 搜索历史
|
||||
- UI 设置
|
||||
- 进度状态
|
||||
|
||||
---
|
||||
|
||||
## 7. 抓取流程(当前实际运行路径)
|
||||
|
||||
### 7.1 点击“开始获取”后的流程
|
||||
|
||||
1. 读取页码范围 / 关键词 / 速度配置
|
||||
2. 构建 `searchContext`
|
||||
3. 显示:
|
||||
- `正在规划缓存:检查本地缓存 / 云端规划 / 复用块...`
|
||||
4. 调用 `getCachedCoveragePlan(...)`
|
||||
5. 依次判断:
|
||||
- exact coverage
|
||||
- assembled page coverage
|
||||
- 服务端 planning
|
||||
- cachedBlocks
|
||||
- shiftedCoverage
|
||||
6. 对缺口区间执行 live 抓取
|
||||
7. 结果与缓存合并后,再写回本地与云端
|
||||
|
||||
### 7.2 实际搜索执行路径
|
||||
|
||||
主要函数:
|
||||
|
||||
- `fetchFromPage(...)`
|
||||
- `fetchLivePageRange(...)`
|
||||
- `processCachedThreadBatch(...)`
|
||||
- `applyCachedMagnetHits(...)`
|
||||
|
||||
---
|
||||
|
||||
## 8. 去重与上传抑制策略
|
||||
|
||||
### 8.1 本地去重
|
||||
|
||||
- 线程去重:`normalizeCachedThreads()`
|
||||
- 磁链去重:`normalizeMagnetList()`
|
||||
- coverage 合并去重:`mergeCoverageThreads()`
|
||||
- coverage 保存前合并:`mergeCoverageThreadLists()`
|
||||
|
||||
### 8.2 云端去重
|
||||
|
||||
数据库唯一索引 + `ON DUPLICATE KEY UPDATE`
|
||||
|
||||
### 8.3 上传抑制
|
||||
|
||||
本地维护 upload meta:
|
||||
|
||||
- `threads`
|
||||
- `coverages`
|
||||
- `pages`
|
||||
|
||||
每类记录:
|
||||
- `payloadHash`
|
||||
- `lastUploadedAt`
|
||||
|
||||
TTL:
|
||||
|
||||
- 线程:10 分钟
|
||||
- 页:30 分钟
|
||||
- 范围:60 分钟
|
||||
|
||||
作用:
|
||||
- 降低重复上传
|
||||
- 降低重复加密
|
||||
- 降低数据库写入压力
|
||||
|
||||
---
|
||||
|
||||
## 9. 当前“智能更新”逻辑
|
||||
|
||||
### 已实现
|
||||
|
||||
1. front refresh(从第一页开始时可刷新前段)
|
||||
2. shiftedCoverage 复用
|
||||
3. page cache block 复用
|
||||
4. intersecting coverage fragments 复用
|
||||
5. 服务端 coverage planning
|
||||
6. Redis 缓存 planning 结果
|
||||
|
||||
### 当前仍不够理想的点
|
||||
|
||||
1. **thread cache 很多,但未完全反向沉淀成 page/coverage 索引**
|
||||
这会导致“明明有很多历史线程缓存,但大范围规划不够聪明”。
|
||||
|
||||
2. **coverage 合并仍然偏保守**
|
||||
少量前页更新后,虽然已经补了历史 coverage 合并,但仍建议继续增强“旧帖保留连续性”。
|
||||
|
||||
3. **前段刷新策略仍是经验型策略**
|
||||
当前已从“固定刷新前 20 页”改为“前面没有缓存覆盖才刷新”,但仍属于启发式策略。
|
||||
|
||||
4. **启动前规划仍然是同步等待**
|
||||
现在已经加了状态提示,但未来可以做:
|
||||
- 本地快速规划优先
|
||||
- 云端规划异步补充
|
||||
|
||||
---
|
||||
|
||||
## 10. 线上部署信息(不含敏感凭据)
|
||||
|
||||
### 服务器基础信息
|
||||
|
||||
- 公网 IP:`47.238.173.98`
|
||||
- 域名:`s.52oai.com`
|
||||
- 系统:CentOS 7(当前会话已确认)
|
||||
- 部署方式:Nginx + PM2 + Node.js + MySQL + Redis
|
||||
- 面板:宝塔面板(BT Panel)
|
||||
|
||||
### 当前服务端组成
|
||||
|
||||
- Nginx:反向代理入口
|
||||
- Node 服务:`magnet-cloud-cache`
|
||||
- PM2:进程守护
|
||||
- MySQL:主数据存储
|
||||
- Redis:coverage planning 结果缓存
|
||||
|
||||
### 线上访问入口
|
||||
|
||||
- `https://s.52oai.com/health`
|
||||
- `https://s.52oai.com/ready`
|
||||
|
||||
### Redis 状态
|
||||
|
||||
当前已验证:
|
||||
|
||||
- Redis 可连接
|
||||
- 可写入/读取 JSON
|
||||
- coverage planning key 已落库
|
||||
|
||||
---
|
||||
|
||||
## 11. 当前安全边界
|
||||
|
||||
### 已完成的安全改造
|
||||
|
||||
1. 云同步登录/注册 UI 已迁移到扩展独立页面
|
||||
2. 收藏/搜索历史已迁移到扩展私有存储
|
||||
3. session 进度备份已不再依赖页面 `sessionStorage`
|
||||
4. shared-cache 写接口已加写入令牌鉴权
|
||||
5. shared-cache 已避免旧写覆盖新写
|
||||
|
||||
### 仍建议后续加强
|
||||
|
||||
1. `vaultKeyBase64` 改为 session-only
|
||||
2. auth/vault/shared-cache 更细粒度限流
|
||||
3. 服务端请求体大小再收紧
|
||||
4. 共享缓存写审计日志
|
||||
|
||||
---
|
||||
|
||||
## 12. 当前最值得继续优化的方向
|
||||
|
||||
### P1 - 正确性优先
|
||||
|
||||
1. **thread cache 反向沉淀成 page/coverage 索引**
|
||||
这是目前提升“大范围缓存命中率”的最高价值项。
|
||||
|
||||
2. **coverage 合并策略继续增强**
|
||||
尤其是前段少量更新后,历史旧帖不应被挤出当前范围搜索。
|
||||
|
||||
3. **规划器结果可观测化**
|
||||
建议增加 debug summary:
|
||||
- 命中 exact coverage
|
||||
- 命中 page blocks 数
|
||||
- 命中 intersecting coverage 数
|
||||
- 实际缺口页数
|
||||
|
||||
### P2 - 性能优先
|
||||
|
||||
1. 本地快速规划优先,云端 planning 异步补充
|
||||
2. planning 结果本地短时缓存
|
||||
3. 回填队列分批化
|
||||
|
||||
---
|
||||
|
||||
## 12.1 当前抓取合并 / 去重 / 智能更新逻辑复盘
|
||||
|
||||
### 一、当前逻辑里已经做对的部分
|
||||
|
||||
1. **线程级去重已经明确**
|
||||
- 本地通过 `threadKey` 去重
|
||||
- 云端通过 `(forum_key, thread_key)` 唯一约束去重
|
||||
|
||||
2. **磁链级去重已经明确**
|
||||
- 本地通过 `normalizeMagnetList()` 去重
|
||||
- 云端共享缓存目前按线程保存,磁链集合在单线程内部去重
|
||||
|
||||
3. **coverage 规划已经从“只看精确范围”进化为多层来源**
|
||||
当前 `getCachedCoveragePlan()` 已经综合:
|
||||
- exact coverage
|
||||
- assembled page coverage
|
||||
- 服务端 planning
|
||||
- cachedBlocks(页块)
|
||||
- intersecting coverages(相交范围碎片)
|
||||
- shiftedCoverage
|
||||
|
||||
4. **上传抑制已经开始发挥作用**
|
||||
- thread/page/coverage 都有 upload meta
|
||||
- hash + TTL 可以减少重复上云
|
||||
|
||||
### 二、当前逻辑不够理想的核心点
|
||||
|
||||
#### 1. 线程缓存与页/范围索引仍然是“半脱节”
|
||||
|
||||
这是目前最关键的问题:
|
||||
|
||||
- `threads` 里可能已经积累了大量帖子与磁链
|
||||
- 但这些历史数据没有系统性地回灌成:
|
||||
- `pageCoverages`
|
||||
- `coverages`
|
||||
|
||||
结果就是:
|
||||
|
||||
> 看起来缓存很多,但真正能参与“大范围规划”的缓存块不够多。
|
||||
|
||||
#### 2. 智能更新仍然偏启发式,而不是差异驱动
|
||||
|
||||
当前 front refresh / shiftedCoverage 还是基于:
|
||||
|
||||
- 从第 1 页开始时,倾向刷新前段
|
||||
- 历史范围快照的最近性
|
||||
|
||||
但它缺少真正的:
|
||||
|
||||
- 基于“帖子是否变化”的差异判断
|
||||
- 基于线程游标 / 内容指纹 / 页指纹 的更新模型
|
||||
|
||||
#### 3. coverage 合并虽然已经补强,但仍然不够结构化
|
||||
|
||||
当前已经补了:
|
||||
|
||||
- 保存新 coverage 前,合并历史相交 coverage 的线程
|
||||
|
||||
但这个策略仍是:
|
||||
|
||||
- 按线程集合合并
|
||||
- 不是按“真实页结构”或“线程页位置偏移”合并
|
||||
|
||||
所以在论坛前段轻微更新时,仍可能出现:
|
||||
|
||||
- 范围快照连续性不足
|
||||
- 旧帖可见性波动
|
||||
|
||||
#### 4. 服务端 planning 已加入,但仍然是“辅助规划器”
|
||||
|
||||
现在服务端已经能返回:
|
||||
|
||||
- `exactCoverage`
|
||||
- `cachedBlocks`
|
||||
- `shiftedCoverage`
|
||||
|
||||
但扩展端仍保留了大量本地规划逻辑,导致:
|
||||
|
||||
- 本地规划与服务端规划并存
|
||||
- 逻辑复杂度上升
|
||||
- 调试成本高
|
||||
|
||||
### 三、当前最值得优先继续优化的技术方向
|
||||
|
||||
#### A. `threads -> pageCoverages / coverages` 反向沉淀
|
||||
|
||||
这是正确性和命中率的第一优先级优化。
|
||||
|
||||
目标:
|
||||
|
||||
- 当系统手里已经有大量 thread cache 时
|
||||
- 能定期 / 按需将其重建为:
|
||||
- page coverage block
|
||||
- coverage fragment
|
||||
|
||||
收益:
|
||||
|
||||
- “缓存明明很多却命中不上”的问题会明显缓解
|
||||
|
||||
#### B. 引入“线程或页面指纹”作为智能更新依据
|
||||
|
||||
例如:
|
||||
|
||||
- 页面线程列表 hash
|
||||
- 范围线程集合 hash
|
||||
- 线程集合版本号
|
||||
|
||||
这样 front refresh 就能从“经验刷新”变成:
|
||||
|
||||
- 先比对指纹
|
||||
- 只有前段真的变化才刷新
|
||||
|
||||
#### C. 最终把 planning 统一到服务端主导
|
||||
|
||||
建议目标不是“双规划器”,而是:
|
||||
|
||||
- 服务端负责 coverage planning
|
||||
- 扩展端只负责执行计划
|
||||
- 本地逻辑只保留兜底回退
|
||||
|
||||
这样架构会明显更稳。
|
||||
|
||||
### P3 - 运维优先
|
||||
|
||||
1. 共享缓存保留策略 / 归档策略
|
||||
2. 写入审计与异常监控
|
||||
3. 密钥轮换机制
|
||||
|
||||
---
|
||||
|
||||
## 13. 当前版本的整体评价
|
||||
|
||||
### 已经具备的能力
|
||||
|
||||
- 本地线程 / 范围 / 页缓存
|
||||
- 云端共享缓存
|
||||
- 私有保险柜
|
||||
- 上传抑制与 TTL 节流
|
||||
- coverage planning + Redis planning cache
|
||||
- 扩展端 UI / 云同步中心 / 收藏 / 搜索历史
|
||||
|
||||
### 当前最核心的短板
|
||||
|
||||
> 缓存数据已经很多,但“如何把这些缓存组织成最优复用计划”仍然不够强。
|
||||
|
||||
也就是说,当前系统最难的不是“存缓存”,而是:
|
||||
|
||||
> **把已有缓存规划成正确、完整、连续的搜索路径**
|
||||
|
||||
---
|
||||
|
||||
## 14. 文档使用建议
|
||||
|
||||
如果后续你继续迭代项目,建议把本文档当成:
|
||||
|
||||
1. **新人接手说明**
|
||||
2. **线上部署说明**
|
||||
3. **缓存/同步逻辑总览**
|
||||
4. **后续重构路线图**
|
||||
|
||||
---
|
||||
|
||||
## 15. 敏感信息说明
|
||||
|
||||
本文档**不记录**以下信息:
|
||||
|
||||
- SSH 密码
|
||||
- MySQL 密码
|
||||
- write token
|
||||
- 加密密钥
|
||||
- `.env` 明文内容
|
||||
|
||||
这些信息应只保留在安全的部署环境与秘密管理系统中。
|
||||
|
||||
---
|
||||
|
||||
## 16. Chrome 分发与更新能力边界
|
||||
|
||||
结合当前平台限制和架构评估,下面是**真实可行**与**不可行**的边界。
|
||||
|
||||
### 16.1 对普通 Chrome 用户,不可直接做到的事
|
||||
|
||||
以下能力在普通非企业托管 Chrome 中,**不现实或不受支持**:
|
||||
|
||||
1. **从网站自动把扩展装进 Chrome**
|
||||
2. **通过主页密码后,静默安装 CRX**
|
||||
3. **普通用户使用自托管 CRX 自动更新**
|
||||
|
||||
也就是说:
|
||||
|
||||
> `s.52oai.com` 可以做“分发引导页”,但不能真正实现“输密码后自动装到 Chrome 里”。
|
||||
|
||||
### 16.2 最现实的分发方式
|
||||
|
||||
对于普通用户,最推荐:
|
||||
|
||||
1. **发布到 Chrome Web Store(建议 unlisted)**
|
||||
2. `s.52oai.com` 做密码门禁页
|
||||
3. 门禁通过后展示:
|
||||
- CWS 安装链接
|
||||
- 安装说明
|
||||
- 使用说明
|
||||
|
||||
这样可以获得:
|
||||
|
||||
- 官方安装路径
|
||||
- Chrome 自动更新
|
||||
- 最低用户支持成本
|
||||
|
||||
### 16.3 自托管更新什么时候才成立
|
||||
|
||||
只有在以下场景,自托管更新才是现实方案:
|
||||
|
||||
- **企业托管 Chrome**
|
||||
- 管理员通过策略安装
|
||||
- 使用企业设备 / 组织统一管理浏览器
|
||||
|
||||
这不是普通公网用户场景。
|
||||
|
||||
### 16.4 当前项目建议的分发策略
|
||||
|
||||
如果未来要正式分发,推荐顺序:
|
||||
|
||||
1. **Chrome Web Store(最好 Unlisted)**
|
||||
2. `s.52oai.com` 做密码门禁与引导页
|
||||
3. 扩展内做“最低支持版本”检查
|
||||
4. 如果版本太低,则提示用户去商店更新
|
||||
|
||||
### 16.5 站点密码的真实作用
|
||||
|
||||
主页密码(例如 `123456`)只能充当:
|
||||
|
||||
- 引导门槛
|
||||
- 简单访问控制
|
||||
- 降低无关访问
|
||||
|
||||
但它**不能视为真正安全边界**。真正的权限控制仍然必须放在:
|
||||
|
||||
- 后端账号系统
|
||||
- API 鉴权
|
||||
- 服务端授权逻辑
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 16. Chrome 分发与更新能力边界(部署到主页的现实约束)
|
||||
|
||||
### 16.1 对普通 Chrome 用户,不能直接做到的事
|
||||
|
||||
对于**普通、非企业托管**的 Chrome 用户,当前不支持:
|
||||
|
||||
1. 从 `s.52oai.com` 网页直接自动安装扩展
|
||||
2. 从自有网站静默安装 CRX
|
||||
3. 对普通用户使用自托管 CRX 做自动更新
|
||||
|
||||
也就是说:
|
||||
|
||||
> 网站可以做“入口页 / 密码门禁页 / 引导页”,但不能真正代替 Chrome 官方安装渠道完成自动安装。
|
||||
|
||||
### 16.2 对普通用户最现实的方案
|
||||
|
||||
最可行方案是:
|
||||
|
||||
1. 发布到 **Chrome Web Store(建议 Unlisted)**
|
||||
2. `s.52oai.com` 作为密码门禁页
|
||||
3. 输入密码后展示:
|
||||
- Chrome Web Store 安装链接
|
||||
- 使用说明
|
||||
- 更新说明
|
||||
|
||||
这样可以获得:
|
||||
|
||||
- 标准安装体验
|
||||
- Chrome 自动更新
|
||||
- 最低的用户支持成本
|
||||
|
||||
### 16.3 自托管自动更新什么时候可行
|
||||
|
||||
只有在:
|
||||
|
||||
- 企业托管 Chrome
|
||||
- 管理员策略安装
|
||||
- 受管设备/受管浏览器环境
|
||||
|
||||
这种情况下,自托管 CRX + update manifest 才是现实路径。
|
||||
|
||||
### 16.4 当前项目推荐的分发模式
|
||||
|
||||
推荐顺序:
|
||||
|
||||
1. **Chrome Web Store(Unlisted)** 作为主分发渠道
|
||||
2. `s.52oai.com` 做密码门禁 + 安装引导页
|
||||
3. 扩展内调用服务端 `/ext/config`(后续可实现)检查最低支持版本
|
||||
4. 如果版本过低,提示用户跳转到商店升级
|
||||
|
||||
### 16.5 密码门禁的真实作用
|
||||
|
||||
密码门禁只能起到:
|
||||
|
||||
- 隐藏安装入口
|
||||
- 控制谁能看到链接
|
||||
|
||||
但它**不能视为真正的安全边界**。真正的权限控制必须放在:
|
||||
|
||||
- 后端账号体系
|
||||
- API token / 登录态
|
||||
- 服务器侧权限判断
|
||||
2065
background.js
2065
background.js
File diff suppressed because it is too large
Load Diff
1800
content.js
1800
content.js
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "涩花塘磁力助手",
|
||||
"version": "1.2",
|
||||
"description": "一键获取当前页所有帖子的磁力链接",
|
||||
"version": "1.3",
|
||||
"description": "一键获取磁力链接 - 暗色科技风UI + 收藏夹 + 搜索历史 + 完成通知",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"clipboardWrite",
|
||||
"storage",
|
||||
"unlimitedStorage"
|
||||
"unlimitedStorage",
|
||||
"notifications"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://sehuatang.net/*",
|
||||
@@ -17,7 +18,9 @@
|
||||
"https://sehuatang.net/*",
|
||||
"https://www.sehuatang.net/*",
|
||||
"https://sehuatang.org/*",
|
||||
"https://www.sehuatang.org/*"
|
||||
"https://www.sehuatang.org/*",
|
||||
"http://s.52oai.com/*",
|
||||
"https://s.52oai.com/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
|
||||
146
popup.html
146
popup.html
@@ -1,27 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>磁力助手</title>
|
||||
<style>
|
||||
body { width: 200px; padding: 15px; font-family: Arial; }
|
||||
h3 { margin: 0 0 10px; font-size: 14px; }
|
||||
p { font-size: 12px; color: #666; margin: 5px 0; }
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
:root {
|
||||
--m-bg-deep: #0a0e14;
|
||||
--m-bg-primary: #0f1419;
|
||||
--m-bg-secondary: #1a1f2e;
|
||||
--m-bg-card: rgba(26, 31, 46, 0.92);
|
||||
--m-accent: #00d4aa;
|
||||
--m-accent-2: #00f5c4;
|
||||
--m-danger: #ef4444;
|
||||
--m-text-primary: #f0f4f8;
|
||||
--m-text-secondary: #8892a4;
|
||||
--m-text-muted: #5c6578;
|
||||
--m-border: rgba(255,255,255,0.08);
|
||||
--m-radius: 12px;
|
||||
}
|
||||
button:hover { background: #45a049; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--m-bg-primary);
|
||||
color: var(--m-text-primary);
|
||||
font: 13px/1.5 "Microsoft YaHei", sans-serif;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:16px; }
|
||||
.title { font-size: 18px; font-weight: 700; color: var(--m-accent); }
|
||||
.subtitle { font-size: 11px; color: var(--m-text-secondary); }
|
||||
.header-status { display:flex; flex-direction:column; align-items:flex-end; gap:6px; min-width:120px; }
|
||||
.header-status .status { margin-top:0; }
|
||||
.card {
|
||||
background: var(--m-bg-card);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-title { font-size: 13px; font-weight: 700; margin-bottom: 8px; }
|
||||
.meta { color: var(--m-text-secondary); font-size: 12px; }
|
||||
.row { display:flex; gap:8px; margin-top:10px; }
|
||||
.col { display:flex; flex-direction:column; gap:10px; }
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--m-radius);
|
||||
border: 1px solid var(--m-border);
|
||||
background: var(--m-bg-secondary);
|
||||
color: var(--m-text-primary);
|
||||
outline: none;
|
||||
}
|
||||
input:focus { border-color: var(--m-accent); }
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--m-radius);
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.primary { background: linear-gradient(135deg, var(--m-accent), var(--m-accent-2)); color: var(--m-bg-deep); }
|
||||
.secondary { background: var(--m-bg-secondary); color: var(--m-text-primary); border: 1px solid var(--m-border); }
|
||||
.danger { background: rgba(239,68,68,0.16); color: #ff8585; border: 1px solid rgba(239,68,68,0.25); }
|
||||
.status { display:flex; align-items:center; gap:8px; margin-top:8px; }
|
||||
.dot { width: 8px; height: 8px; border-radius:50%; background: var(--m-danger); box-shadow: 0 0 8px rgba(239,68,68,.35); }
|
||||
.dot.ok { background:#10b981; box-shadow:0 0 8px rgba(16,185,129,.35); }
|
||||
.small { font-size: 11px; color: var(--m-text-muted); }
|
||||
.hidden { display:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>磁力链接复制助手</h3>
|
||||
<p>在论坛页面提取当前页磁力链接并复制到剪贴板</p>
|
||||
<button id="copyBtn">手动复制当前页面</button>
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="title">MAGNET CLOUD</div>
|
||||
<div class="subtitle">扩展独立云同步中心</div>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<div class="status"><span id="accountDot" class="dot"></span><span id="accountText" class="meta">账号状态加载中</span></div>
|
||||
<div class="status"><span id="serverDot" class="dot"></span><span id="serverText" class="meta">服务器状态加载中</span></div>
|
||||
<div id="syncEmail" class="small"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card col">
|
||||
<div class="card-title">云端状态看板</div>
|
||||
<div id="cloudStatsSummary" class="meta">正在读取云端统计...</div>
|
||||
<div id="cloudStatsLatest" class="small"></div>
|
||||
<div id="cloudStatsTables" class="small"></div>
|
||||
</div>
|
||||
|
||||
<div class="card col">
|
||||
<div class="card-title">本地状态看板</div>
|
||||
<div id="localStatsSummary" class="meta">正在读取本地统计...</div>
|
||||
<div id="localStatsStorage" class="small"></div>
|
||||
<div id="localStatsMeta" class="small"></div>
|
||||
</div>
|
||||
|
||||
<div id="authLoggedOut" class="card col">
|
||||
<div class="card-title">登录 / 注册</div>
|
||||
<div class="meta">账号密码只会在扩展页面中输入,不再放在网页里。</div>
|
||||
<input id="emailInput" type="text" placeholder="邮箱">
|
||||
<input id="passwordInput" type="password" placeholder="密码,至少6位">
|
||||
<div class="row">
|
||||
<button id="loginBtn" class="primary" type="button">登录</button>
|
||||
<button id="registerBtn" class="secondary" type="button">注册</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="authLoggedIn" class="card col hidden">
|
||||
<div class="card-title">已登录</div>
|
||||
<div id="loggedInEmail" class="meta"></div>
|
||||
<div class="row">
|
||||
<button id="syncNowBtn" class="primary" type="button">立即同步</button>
|
||||
<button id="logoutBtn" class="danger" type="button">退出登录</button>
|
||||
</div>
|
||||
<div id="syncProgressText" class="small"></div>
|
||||
</div>
|
||||
|
||||
<div class="card col">
|
||||
<div class="card-title">下载器推荐</div>
|
||||
<div class="meta">如果点击磁力“下载”按钮没有反应,请先安装本地下载器。qBittorrent 更适合磁力资源,Motrix 更适合偏轻量的使用习惯。</div>
|
||||
<div class="row">
|
||||
<button id="downloadNode2Btn" class="primary" type="button">qB 节点2(推荐)</button>
|
||||
<button id="downloadOfficialBtn" class="secondary" type="button">qB 官方节点</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="downloadMotrixBtn" class="secondary" type="button">Motrix 下载</button>
|
||||
</div>
|
||||
<div class="small">安装完成后,请确保 qBittorrent 已关联 magnet 磁力链接协议。</div>
|
||||
</div>
|
||||
|
||||
<div id="message" class="small"></div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
298
popup.js
298
popup.js
@@ -1,33 +1,277 @@
|
||||
document.getElementById('copyBtn').addEventListener('click', function() {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, function(tabs) {
|
||||
var activeTab = tabs && tabs[0];
|
||||
if (!activeTab || typeof activeTab.id !== 'number') {
|
||||
alert('未找到当前标签页');
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.tabs.sendMessage(activeTab.id, { action: 'getMagnets' }, function(response) {
|
||||
function sendMessageToBackground(message) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
chrome.runtime.sendMessage(message, function(response) {
|
||||
if (chrome.runtime.lastError) {
|
||||
alert('当前页面不支持,请在涩花塘论坛页面使用');
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
|
||||
var magnets = response && Array.isArray(response.magnets) ? response.magnets : [];
|
||||
magnets = Array.from(new Set(magnets));
|
||||
|
||||
if (magnets.length === 0) {
|
||||
alert('当前页面未找到磁力链接');
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(magnets.join('\n'))
|
||||
.then(function() {
|
||||
alert('已复制 ' + magnets.length + ' 个磁力链接!');
|
||||
})
|
||||
.catch(function(err) {
|
||||
var errorMsg = err && err.message ? err.message : '复制失败';
|
||||
alert('复制失败:' + errorMsg);
|
||||
});
|
||||
resolve(response || null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setMessage(text, isError) {
|
||||
var message = document.getElementById('message');
|
||||
if (!message) return;
|
||||
message.textContent = text || '';
|
||||
message.style.color = isError ? '#ff8585' : '#8892a4';
|
||||
}
|
||||
|
||||
var lastGoodCloudStats = null;
|
||||
var lastGoodSyncStatus = null;
|
||||
var syncProgressTimer = null;
|
||||
var lastServerHealthy = null;
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function setSyncProgress(text) {
|
||||
var el = document.getElementById('syncProgressText');
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
}
|
||||
|
||||
function startSyncProgress(prefix) {
|
||||
var startedAt = Date.now();
|
||||
var dots = 0;
|
||||
if (syncProgressTimer) {
|
||||
clearInterval(syncProgressTimer);
|
||||
}
|
||||
setSyncProgress((prefix || '正在同步') + ' 0s');
|
||||
syncProgressTimer = setInterval(function() {
|
||||
dots = (dots + 1) % 4;
|
||||
var suffix = new Array(dots + 1).join('.');
|
||||
var elapsed = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
|
||||
setSyncProgress((prefix || '正在同步') + suffix + ' ' + elapsed + 's');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function stopSyncProgress(text) {
|
||||
if (syncProgressTimer) {
|
||||
clearInterval(syncProgressTimer);
|
||||
syncProgressTimer = null;
|
||||
}
|
||||
setSyncProgress(text || '');
|
||||
}
|
||||
|
||||
function updateStatus(status) {
|
||||
if (status && status.authenticated !== undefined) {
|
||||
lastGoodSyncStatus = status;
|
||||
}
|
||||
var accountDot = document.getElementById('accountDot');
|
||||
var accountText = document.getElementById('accountText');
|
||||
var email = document.getElementById('syncEmail');
|
||||
var loggedOut = document.getElementById('authLoggedOut');
|
||||
var loggedIn = document.getElementById('authLoggedIn');
|
||||
var loggedInEmail = document.getElementById('loggedInEmail');
|
||||
var healthy = !!(status && status.color === 'green');
|
||||
var authenticated = !!(status && status.authenticated);
|
||||
if (accountDot) accountDot.classList.toggle('ok', healthy);
|
||||
if (accountText) accountText.textContent = status && (status.accountText || status.text) ? (status.accountText || status.text) : '账号状态未知';
|
||||
if (email) email.textContent = status && status.email ? ('账号:' + status.email) : '';
|
||||
if (loggedOut) loggedOut.classList.toggle('hidden', authenticated);
|
||||
if (loggedIn) loggedIn.classList.toggle('hidden', !authenticated);
|
||||
if (loggedInEmail) loggedInEmail.textContent = status && status.email ? ('当前账号:' + status.email) : '当前账号:未知';
|
||||
}
|
||||
|
||||
function updateServerStatus(ok, text) {
|
||||
var serverDot = document.getElementById('serverDot');
|
||||
var serverText = document.getElementById('serverText');
|
||||
lastServerHealthy = !!ok;
|
||||
if (serverDot) serverDot.classList.toggle('ok', !!ok);
|
||||
if (serverText) serverText.textContent = text || (ok ? '服务器状态正常' : '服务器状态异常');
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '暂无';
|
||||
try {
|
||||
var date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString('zh-CN');
|
||||
} catch (error) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCloudStats(stats) {
|
||||
var summary = document.getElementById('cloudStatsSummary');
|
||||
var latest = document.getElementById('cloudStatsLatest');
|
||||
var tables = document.getElementById('cloudStatsTables');
|
||||
if (!summary || !latest || !tables) return;
|
||||
if (!stats || !stats.ok) {
|
||||
if (lastGoodCloudStats && lastGoodCloudStats.ok) {
|
||||
updateServerStatus(true, '服务器状态正常(展示最近一次成功数据)');
|
||||
summary.textContent = '云端状态暂时刷新失败,显示最近一次成功数据';
|
||||
latest.textContent = '最近成功更新:线程 ' + formatDateTime(lastGoodCloudStats.latest.threads) + ' | 范围 ' + formatDateTime(lastGoodCloudStats.latest.coverages) + ' | 页缓存 ' + formatDateTime(lastGoodCloudStats.latest.pages);
|
||||
tables.textContent = '上次空间占用:' + (Array.isArray(lastGoodCloudStats.tables) ? lastGoodCloudStats.tables.map(function(table) {
|
||||
return table.tableName + ' ' + table.sizeMb + 'MB';
|
||||
}).join(' | ') : '暂无');
|
||||
return;
|
||||
}
|
||||
updateServerStatus(false, '服务器状态暂时不可读');
|
||||
summary.textContent = '暂时无法读取云端状态';
|
||||
latest.textContent = '';
|
||||
tables.textContent = '';
|
||||
return;
|
||||
}
|
||||
updateServerStatus(true, '服务器状态正常');
|
||||
lastGoodCloudStats = stats;
|
||||
summary.textContent = '帖子索引 ' + stats.counts.threads + ' 条(含磁力 ' + (stats.counts.magnetThreads || 0) + ' 条)/ 页缓存 ' + stats.counts.pages + ' 条 / 范围缓存 ' + stats.counts.coverages + ' 条 / 用户 ' + stats.counts.users + ' 个 / 保险柜 ' + stats.counts.vaultItems + ' 条';
|
||||
latest.textContent = '最近更新:线程 ' + formatDateTime(stats.latest.threads) + ' | 范围 ' + formatDateTime(stats.latest.coverages) + ' | 页缓存 ' + formatDateTime(stats.latest.pages);
|
||||
tables.textContent = '空间占用:' + (Array.isArray(stats.tables) ? stats.tables.map(function(table) {
|
||||
return table.tableName + ' ' + table.sizeMb + 'MB';
|
||||
}).join(' | ') : '暂无');
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
var value = Number(bytes || 0);
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
||||
if (value < 1024) return value + ' B';
|
||||
if (value < 1024 * 1024) return (value / 1024).toFixed(1) + ' KB';
|
||||
if (value < 1024 * 1024 * 1024) return (value / 1024 / 1024).toFixed(2) + ' MB';
|
||||
return (value / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
function renderLocalStats(stats) {
|
||||
var summary = document.getElementById('localStatsSummary');
|
||||
var storage = document.getElementById('localStatsStorage');
|
||||
var meta = document.getElementById('localStatsMeta');
|
||||
if (!summary || !storage || !meta) return;
|
||||
if (!stats || !stats.ok) {
|
||||
summary.textContent = '暂时无法读取本地状态';
|
||||
storage.textContent = '';
|
||||
meta.textContent = '';
|
||||
return;
|
||||
}
|
||||
summary.textContent = '帖子索引 ' + stats.counts.threads + ' 条(含磁力 ' + (stats.counts.magnetThreads || 0) + ' 条)/ 页缓存 ' + stats.counts.pages + ' 条 / 范围缓存 ' + stats.counts.coverages + ' 条 / 收藏 ' + stats.counts.favorites + ' 条 / 历史 ' + stats.counts.history + ' 条';
|
||||
storage.textContent = '本地存储占用:' + formatBytes(stats.storage.usage) + ' / ' + formatBytes(stats.storage.quota);
|
||||
meta.textContent = '上传抑制元数据:线程 ' + stats.counts.uploadMetaThreads + ' 条 | 范围 ' + stats.counts.uploadMetaCoverages + ' 条 | 页 ' + stats.counts.uploadMetaPages + ' 条';
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
var response = await sendMessageToBackground({ action: 'cloudGetSyncStatus' });
|
||||
if (response && response.ok !== false) {
|
||||
updateStatus(response || { color: 'red', text: '状态未知', authenticated: false });
|
||||
return;
|
||||
}
|
||||
if (lastGoodSyncStatus) {
|
||||
updateStatus(lastGoodSyncStatus);
|
||||
setMessage('云同步状态暂时刷新失败,显示最近一次成功状态', true);
|
||||
return;
|
||||
}
|
||||
updateStatus({ color: 'red', text: '云同步异常', authenticated: false, email: '' });
|
||||
} catch (error) {
|
||||
if (lastGoodSyncStatus) {
|
||||
updateStatus(lastGoodSyncStatus);
|
||||
setMessage('云同步状态暂时刷新失败,显示最近一次成功状态', true);
|
||||
return;
|
||||
}
|
||||
updateStatus({ color: 'red', text: '云同步异常', authenticated: false, email: '' });
|
||||
setMessage('状态获取失败:' + error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCloudStats() {
|
||||
var attempts = 0;
|
||||
var response = null;
|
||||
var error = null;
|
||||
for (attempts = 0; attempts < 3; attempts++) {
|
||||
try {
|
||||
response = await sendMessageToBackground({ action: 'cloudGetCacheStats' });
|
||||
if (response && response.ok) {
|
||||
renderCloudStats(response);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
await wait(500 * (attempts + 1));
|
||||
}
|
||||
renderCloudStats(null);
|
||||
if (error) {
|
||||
setMessage('云端状态暂时刷新失败,稍后会自动重试', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLocalStats() {
|
||||
try {
|
||||
var response = await sendMessageToBackground({ action: 'localGetCacheStats' });
|
||||
renderLocalStats(response);
|
||||
} catch (error) {
|
||||
renderLocalStats(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAuth(action) {
|
||||
var emailInput = document.getElementById('emailInput');
|
||||
var passwordInput = document.getElementById('passwordInput');
|
||||
var email = emailInput ? emailInput.value.trim() : '';
|
||||
var password = passwordInput ? passwordInput.value : '';
|
||||
if (!email || !password) {
|
||||
setMessage('请输入邮箱和密码', true);
|
||||
return;
|
||||
}
|
||||
setMessage(action === 'cloudRegister' ? '正在注册...' : '正在登录...', false);
|
||||
try {
|
||||
var response = await sendMessageToBackground({ action: action, email: email, password: password });
|
||||
if (!response || !response.ok) {
|
||||
setMessage((action === 'cloudRegister' ? '注册失败:' : '登录失败:') + (response && response.error ? response.error : '未知错误'), true);
|
||||
return;
|
||||
}
|
||||
if (passwordInput) passwordInput.value = '';
|
||||
updateStatus(response.status || { color: 'green', text: '云同步正常', authenticated: true, email: email });
|
||||
setMessage(action === 'cloudRegister' ? '注册成功,云同步已开启' : '登录成功,云同步已开启', false);
|
||||
} catch (error) {
|
||||
setMessage((action === 'cloudRegister' ? '注册失败:' : '登录失败:') + error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncNow() {
|
||||
setMessage('', false);
|
||||
startSyncProgress('正在同步本地缓存到云端');
|
||||
try {
|
||||
await sendMessageToBackground({ action: 'cloudBackfillLocalCache', force: true });
|
||||
stopSyncProgress('已提交同步任务,正在刷新状态...');
|
||||
await wait(1200);
|
||||
startSyncProgress('正在刷新云端状态');
|
||||
await refreshStatus();
|
||||
await refreshCloudStats();
|
||||
await refreshLocalStats();
|
||||
stopSyncProgress('同步完成');
|
||||
setMessage('已触发云端回填', false);
|
||||
} catch (error) {
|
||||
stopSyncProgress('同步失败');
|
||||
setMessage('同步失败:' + error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await sendMessageToBackground({ action: 'cloudLogout' });
|
||||
await refreshStatus();
|
||||
setMessage('已退出登录', false);
|
||||
} catch (error) {
|
||||
setMessage('退出失败:' + error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function openExternalUrl(url) {
|
||||
chrome.tabs.create({ url: url });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('loginBtn').addEventListener('click', function() { submitAuth('cloudLogin'); });
|
||||
document.getElementById('registerBtn').addEventListener('click', function() { submitAuth('cloudRegister'); });
|
||||
document.getElementById('syncNowBtn').addEventListener('click', syncNow);
|
||||
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||
document.getElementById('downloadNode2Btn').addEventListener('click', function() { openExternalUrl('http://7.haory.cn/x/x72/qBittorrent_5.1.4_x64_setup.exe'); });
|
||||
document.getElementById('downloadOfficialBtn').addEventListener('click', function() { openExternalUrl('https://sourceforge.net/projects/qbittorrent/files/qbittorrent-win32/qbittorrent-5.1.4/qbittorrent_5.1.4_x64_setup.exe/download'); });
|
||||
document.getElementById('downloadMotrixBtn').addEventListener('click', function() { openExternalUrl('http://7.haory.cn/x/x72/Motrix-Setup-1.8.19.exe'); });
|
||||
refreshStatus();
|
||||
refreshCloudStats();
|
||||
refreshLocalStats();
|
||||
});
|
||||
|
||||
14
server/.env.example
Normal file
14
server/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
PORT=3200
|
||||
HOST=127.0.0.1
|
||||
LOG_LEVEL=info
|
||||
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=magnet_app
|
||||
MYSQL_PASSWORD=change_me
|
||||
MYSQL_DATABASE=magnet_cloud
|
||||
|
||||
APP_ENCRYPTION_SECRET=change_this_to_a_long_random_secret
|
||||
SHARED_CACHE_WRITE_TOKEN=change_this_to_a_long_random_write_token
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
REDIS_ENABLED=true
|
||||
27
server/README.md
Normal file
27
server/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Magnet Cloud Cache Server
|
||||
|
||||
共享缓存池后端最小骨架:
|
||||
|
||||
- Fastify 4
|
||||
- MySQL 5.7+/8.0+
|
||||
- AES-256-GCM 数据库存储加密
|
||||
- 共享线程缓存 / 范围缓存 / 页缓存
|
||||
- 为未来账号体系和私有保险箱预留表结构
|
||||
|
||||
## 快速启动
|
||||
|
||||
1. 复制 `.env.example` 为 `.env`
|
||||
2. 填好 MySQL 和加密密钥
|
||||
3. 安装依赖:`npm install`
|
||||
4. 启动:`npm start`
|
||||
|
||||
## 已实现接口
|
||||
|
||||
- `GET /health`
|
||||
- `GET /ready`
|
||||
- `POST /api/shared-cache/threads/lookup`
|
||||
- `POST /api/shared-cache/threads/upsert`
|
||||
- `POST /api/shared-cache/coverages/lookup`
|
||||
- `POST /api/shared-cache/coverages/upsert`
|
||||
- `POST /api/shared-cache/pages/lookup`
|
||||
- `POST /api/shared-cache/pages/upsert`
|
||||
829
server/package-lock.json
generated
Normal file
829
server/package-lock.json
generated
Normal file
@@ -0,0 +1,829 @@
|
||||
{
|
||||
"name": "magnet-cloud-cache-server",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "magnet-cloud-cache-server",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/rate-limit": "^8.1.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"fastify": "^4.29.0",
|
||||
"mysql2": "^3.11.5",
|
||||
"redis": "^4.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/ajv-compiler": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
||||
"integrity": "sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.11.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
"fast-uri": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz",
|
||||
"integrity": "sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify-plugin": "^4.0.0",
|
||||
"mnemonist": "0.39.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz",
|
||||
"integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/fast-json-stringify-compiler": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz",
|
||||
"integrity": "sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-json-stringify": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/merge-json-schemas": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz",
|
||||
"integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/rate-limit": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-8.1.1.tgz",
|
||||
"integrity": "sha512-kTaIBuG7hS26rUPermw1RYsobNHxLcqA9AFUbWR8dEyRR8wknZnpfuD3VaJkrtfxyWLW8xZ5b6/GmQ/gNoEfWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify-plugin": "^4.0.0",
|
||||
"ms": "^2.1.3",
|
||||
"tiny-lru": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redis/bloom": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
||||
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/client": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
"yallist": "4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/graph": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
|
||||
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/json": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
|
||||
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/search": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
|
||||
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/time-series": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
|
||||
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz",
|
||||
"integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv/node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/avvio": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz",
|
||||
"integrity": "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/error": "^3.3.0",
|
||||
"fastq": "^1.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-content-type-parse": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
|
||||
"integrity": "sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-decode-uri-component": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
||||
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stringify": {
|
||||
"version": "5.16.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-5.16.1.tgz",
|
||||
"integrity": "sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/merge-json-schemas": "^0.1.0",
|
||||
"ajv": "^8.10.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^2.1.0",
|
||||
"json-schema-ref-resolver": "^1.0.1",
|
||||
"rfdc": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stringify/node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fast-querystring": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
|
||||
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-decode-uri-component": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz",
|
||||
"integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastify": {
|
||||
"version": "4.29.1",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz",
|
||||
"integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/ajv-compiler": "^3.5.0",
|
||||
"@fastify/error": "^3.4.0",
|
||||
"@fastify/fast-json-stringify-compiler": "^4.3.0",
|
||||
"abstract-logging": "^2.0.1",
|
||||
"avvio": "^8.3.0",
|
||||
"fast-content-type-parse": "^1.1.0",
|
||||
"fast-json-stringify": "^5.8.0",
|
||||
"find-my-way": "^8.0.0",
|
||||
"light-my-request": "^5.11.0",
|
||||
"pino": "^9.0.0",
|
||||
"process-warning": "^3.0.0",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"rfdc": "^1.3.0",
|
||||
"secure-json-parse": "^2.7.0",
|
||||
"semver": "^7.5.4",
|
||||
"toad-cache": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastify-plugin": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz",
|
||||
"integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
|
||||
"integrity": "sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-querystring": "^1.0.0",
|
||||
"safe-regex2": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/generic-pool": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
||||
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-ref-resolver": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
|
||||
"integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "5.14.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
|
||||
"integrity": "sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"cookie": "^0.7.0",
|
||||
"process-warning": "^3.0.0",
|
||||
"set-cookie-parser": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru.min": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=1.30.0",
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/mnemonist": {
|
||||
"version": "0.39.6",
|
||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
|
||||
"integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"obliterator": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.19.1",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.19.1.tgz",
|
||||
"integrity": "sha512-yn4zh+Uxu5J3Zvi6Ao96lJ7BSBRkspHflWQAmOPND+htbpIKDQw99TTvPzgihKO/QyMickZopO4OsnixnpcUwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"long": "^5.3.2",
|
||||
"lru.min": "^1.1.4",
|
||||
"named-placeholders": "^1.1.6",
|
||||
"sql-escaper": "^1.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru.min": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obliterator": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^2.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
|
||||
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pino/node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz",
|
||||
"integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
||||
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@redis/bloom": "1.2.0",
|
||||
"@redis/client": "1.6.1",
|
||||
"@redis/graph": "1.1.1",
|
||||
"@redis/json": "1.0.7",
|
||||
"@redis/search": "1.2.0",
|
||||
"@redis/time-series": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ret": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.4.3.tgz",
|
||||
"integrity": "sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex2": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
|
||||
"integrity": "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ret": "~0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
||||
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=2.0.0",
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
||||
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-lru": {
|
||||
"version": "11.4.7",
|
||||
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
|
||||
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/toad-cache": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
||||
"integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
server/package.json
Normal file
20
server/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "magnet-cloud-cache-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node src/index.js",
|
||||
"check": "node --check src/index.js && node --check src/config.js && node --check src/db.js && node --check src/crypto.js && node --check src/auth.js && node --check src/routes/auth.js && node --check src/routes/vault.js && node --check src/routes/shared-cache.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/rate-limit": "^8.1.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"fastify": "^4.29.0",
|
||||
"mysql2": "^3.11.5",
|
||||
"redis": "^4.7.0"
|
||||
}
|
||||
}
|
||||
130
server/sql/001_init.sql
Normal file
130
server/sql/001_init.sql
Normal file
@@ -0,0 +1,130 @@
|
||||
CREATE DATABASE IF NOT EXISTS magnet_cloud CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE magnet_cloud;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
email VARCHAR(191) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
status TINYINT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_users_email (email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
device_name VARCHAR(191) NOT NULL,
|
||||
device_fingerprint VARCHAR(191) NOT NULL,
|
||||
last_seen_at TIMESTAMP NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_devices_user_fp (user_id, device_fingerprint),
|
||||
CONSTRAINT fk_devices_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL,
|
||||
device_fingerprint VARCHAR(191) DEFAULT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
last_seen_at TIMESTAMP NULL DEFAULT NULL,
|
||||
revoked_at TIMESTAMP NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_auth_tokens_hash (token_hash),
|
||||
KEY idx_auth_tokens_user (user_id),
|
||||
KEY idx_auth_tokens_exp (expires_at),
|
||||
CONSTRAINT fk_auth_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_keyrings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
wrapped_dek TEXT NOT NULL,
|
||||
kdf_salt VARCHAR(255) NOT NULL,
|
||||
kdf_params JSON NULL,
|
||||
key_version INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_user_keyrings_user (user_id),
|
||||
CONSTRAINT fk_user_keyrings_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vault_items (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
item_type VARCHAR(64) NOT NULL,
|
||||
item_key VARCHAR(191) NOT NULL,
|
||||
payload_ciphertext LONGTEXT NOT NULL,
|
||||
payload_iv VARCHAR(64) NOT NULL,
|
||||
payload_tag VARCHAR(64) NOT NULL,
|
||||
payload_hash CHAR(64) NOT NULL,
|
||||
key_version INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_vault_user_item (user_id, item_type, item_key),
|
||||
KEY idx_vault_user_type (user_id, item_type),
|
||||
CONSTRAINT fk_vault_items_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_thread_cache (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
forum_key VARCHAR(128) NOT NULL,
|
||||
thread_key VARCHAR(191) NOT NULL,
|
||||
url_hash CHAR(64) NOT NULL,
|
||||
title_hash CHAR(64) NOT NULL,
|
||||
magnet_count INT NOT NULL DEFAULT 0,
|
||||
payload_ciphertext LONGTEXT NOT NULL,
|
||||
payload_iv VARCHAR(64) NOT NULL,
|
||||
payload_tag VARCHAR(64) NOT NULL,
|
||||
payload_hash CHAR(64) NOT NULL,
|
||||
last_seen_at TIMESTAMP NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_shared_thread (forum_key, thread_key),
|
||||
KEY idx_shared_thread_seen (forum_key, last_seen_at),
|
||||
KEY idx_shared_thread_hash (payload_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_coverage_cache (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
forum_key VARCHAR(128) NOT NULL,
|
||||
start_page INT NOT NULL,
|
||||
end_page INT NOT NULL,
|
||||
strategy VARCHAR(64) NOT NULL,
|
||||
thread_count INT NOT NULL DEFAULT 0,
|
||||
crawled_at TIMESTAMP NULL DEFAULT NULL,
|
||||
payload_ciphertext LONGTEXT NOT NULL,
|
||||
payload_iv VARCHAR(64) NOT NULL,
|
||||
payload_tag VARCHAR(64) NOT NULL,
|
||||
payload_hash CHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_shared_coverage (forum_key, start_page, end_page, strategy),
|
||||
KEY idx_shared_coverage_crawled (forum_key, crawled_at),
|
||||
KEY idx_shared_coverage_hash (payload_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_page_cache (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
forum_key VARCHAR(128) NOT NULL,
|
||||
page INT NOT NULL,
|
||||
thread_count INT NOT NULL DEFAULT 0,
|
||||
crawled_at TIMESTAMP NULL DEFAULT NULL,
|
||||
payload_ciphertext LONGTEXT NOT NULL,
|
||||
payload_iv VARCHAR(64) NOT NULL,
|
||||
payload_tag VARCHAR(64) NOT NULL,
|
||||
payload_hash CHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_shared_page (forum_key, page),
|
||||
KEY idx_shared_page_crawled (forum_key, crawled_at),
|
||||
KEY idx_shared_page_hash (payload_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
146
server/src/auth.js
Normal file
146
server/src/auth.js
Normal file
@@ -0,0 +1,146 @@
|
||||
const crypto = require('crypto');
|
||||
const db = require('./db');
|
||||
|
||||
const TOKEN_TTL_DAYS = 30;
|
||||
|
||||
function parseJsonMaybe(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash('sha256').update(String(value || '')).digest('hex');
|
||||
}
|
||||
|
||||
function randomToken(size) {
|
||||
return crypto.randomBytes(size).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function normalizeEmail(email) {
|
||||
return String(email || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function hashPassword(password) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var salt = crypto.randomBytes(16).toString('base64');
|
||||
crypto.scrypt(String(password), salt, 64, function(error, derivedKey) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve('scrypt$' + salt + '$' + derivedKey.toString('base64'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function verifyPassword(password, storedHash) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var parts = String(storedHash || '').split('$');
|
||||
if (parts.length !== 3 || parts[0] !== 'scrypt') {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
var salt = parts[1];
|
||||
var expected = Buffer.from(parts[2], 'base64');
|
||||
crypto.scrypt(String(password), salt, expected.length, function(error, derivedKey) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(crypto.timingSafeEqual(expected, derivedKey));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createAuthToken(userId, deviceFingerprint) {
|
||||
var rawToken = randomToken(32);
|
||||
var expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||
await db.execute(
|
||||
'INSERT INTO auth_tokens (user_id, token_hash, device_fingerprint, expires_at, last_seen_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)',
|
||||
[userId, sha256(rawToken), deviceFingerprint || null, expiresAt]
|
||||
);
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
async function touchAuthToken(tokenHash) {
|
||||
await db.execute('UPDATE auth_tokens SET last_seen_at = CURRENT_TIMESTAMP WHERE token_hash = ?', [tokenHash]);
|
||||
}
|
||||
|
||||
async function revokeAuthToken(tokenHash) {
|
||||
await db.execute('UPDATE auth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE token_hash = ? AND revoked_at IS NULL', [tokenHash]);
|
||||
}
|
||||
|
||||
async function getAuthContextFromToken(rawToken) {
|
||||
if (!rawToken) {
|
||||
return null;
|
||||
}
|
||||
var tokenHash = sha256(rawToken);
|
||||
var rows = await db.query(
|
||||
'SELECT t.id AS token_id, t.user_id, t.token_hash, t.device_fingerprint, t.expires_at, t.revoked_at, u.email, u.status, k.wrapped_dek, k.kdf_salt, k.kdf_params, k.key_version FROM auth_tokens t INNER JOIN users u ON u.id = t.user_id LEFT JOIN user_keyrings k ON k.user_id = u.id WHERE t.token_hash = ? LIMIT 1',
|
||||
[tokenHash]
|
||||
);
|
||||
if (!rows.length) {
|
||||
return null;
|
||||
}
|
||||
var row = rows[0];
|
||||
if (row.revoked_at) {
|
||||
return null;
|
||||
}
|
||||
if (new Date(row.expires_at).getTime() <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
if (Number(row.status || 0) !== 1) {
|
||||
return null;
|
||||
}
|
||||
await touchAuthToken(tokenHash);
|
||||
return {
|
||||
tokenHash: tokenHash,
|
||||
tokenId: row.token_id,
|
||||
user: {
|
||||
id: row.user_id,
|
||||
email: row.email
|
||||
},
|
||||
keyring: row.wrapped_dek ? {
|
||||
wrappedDek: row.wrapped_dek,
|
||||
kdfSalt: row.kdf_salt,
|
||||
kdfParams: parseJsonMaybe(row.kdf_params),
|
||||
keyVersion: Number(row.key_version || 1)
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
async function requireAuth(request, reply) {
|
||||
var header = request.headers.authorization || '';
|
||||
var match = String(header).match(/^Bearer\s+(.+)$/i);
|
||||
if (!match) {
|
||||
reply.code(401);
|
||||
throw new Error('未登录');
|
||||
}
|
||||
var authContext = await getAuthContextFromToken(match[1]);
|
||||
if (!authContext) {
|
||||
reply.code(401);
|
||||
throw new Error('登录已失效');
|
||||
}
|
||||
request.authContext = authContext;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAuthToken,
|
||||
getAuthContextFromToken,
|
||||
hashPassword,
|
||||
normalizeEmail,
|
||||
parseJsonMaybe,
|
||||
requireAuth,
|
||||
revokeAuthToken,
|
||||
sha256,
|
||||
verifyPassword
|
||||
};
|
||||
38
server/src/config.js
Normal file
38
server/src/config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
const envPath = path.join(__dirname, '..', '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
}
|
||||
|
||||
function numberFromEnv(name, fallback) {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
app: {
|
||||
host: process.env.HOST || '127.0.0.1',
|
||||
port: numberFromEnv('PORT', 3200),
|
||||
logLevel: process.env.LOG_LEVEL || 'info'
|
||||
},
|
||||
db: {
|
||||
host: process.env.MYSQL_HOST || '127.0.0.1',
|
||||
port: numberFromEnv('MYSQL_PORT', 3306),
|
||||
user: process.env.MYSQL_USER || '',
|
||||
password: process.env.MYSQL_PASSWORD || '',
|
||||
database: process.env.MYSQL_DATABASE || ''
|
||||
},
|
||||
crypto: {
|
||||
secret: process.env.APP_ENCRYPTION_SECRET || ''
|
||||
},
|
||||
sharedCache: {
|
||||
writeToken: process.env.SHARED_CACHE_WRITE_TOKEN || ''
|
||||
},
|
||||
redis: {
|
||||
enabled: String(process.env.REDIS_ENABLED || 'true').toLowerCase() !== 'false',
|
||||
url: process.env.REDIS_URL || 'redis://127.0.0.1:6379'
|
||||
}
|
||||
};
|
||||
49
server/src/crypto.js
Normal file
49
server/src/crypto.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const crypto = require('crypto');
|
||||
const config = require('./config');
|
||||
|
||||
function getKey() {
|
||||
if (!config.crypto.secret) {
|
||||
throw new Error('APP_ENCRYPTION_SECRET 未配置');
|
||||
}
|
||||
return crypto.createHash('sha256').update(String(config.crypto.secret)).digest();
|
||||
}
|
||||
|
||||
function encryptJson(value) {
|
||||
const plainText = JSON.stringify(value);
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
const payloadHash = crypto.createHash('sha256').update(plainText).digest('hex');
|
||||
|
||||
return {
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
tag: tag.toString('base64'),
|
||||
payloadHash
|
||||
};
|
||||
}
|
||||
|
||||
function decryptJson(record) {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
getKey(),
|
||||
Buffer.from(record.iv, 'base64')
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(record.tag, 'base64'));
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(record.ciphertext, 'base64')),
|
||||
decipher.final()
|
||||
]);
|
||||
return JSON.parse(decrypted.toString('utf8'));
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash('sha256').update(String(value || '')).digest('hex');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encryptJson,
|
||||
decryptJson,
|
||||
sha256
|
||||
};
|
||||
49
server/src/db.js
Normal file
49
server/src/db.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const config = require('./config');
|
||||
|
||||
let pool = null;
|
||||
|
||||
function getPool() {
|
||||
if (pool) {
|
||||
return pool;
|
||||
}
|
||||
|
||||
if (!config.db.host || !config.db.user || !config.db.database) {
|
||||
throw new Error('MySQL 配置不完整');
|
||||
}
|
||||
|
||||
pool = mysql.createPool({
|
||||
host: config.db.host,
|
||||
port: config.db.port,
|
||||
user: config.db.user,
|
||||
password: config.db.password,
|
||||
database: config.db.database,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
charset: 'utf8mb4'
|
||||
});
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
async function query(sql, params) {
|
||||
const [rows] = await getPool().query(sql, params || []);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function execute(sql, params) {
|
||||
const [result] = await getPool().execute(sql, params || []);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function ping() {
|
||||
await query('SELECT 1 AS ok');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPool,
|
||||
query,
|
||||
execute,
|
||||
ping
|
||||
};
|
||||
61
server/src/index.js
Normal file
61
server/src/index.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const Fastify = require('fastify');
|
||||
const cors = require('@fastify/cors');
|
||||
const rateLimit = require('@fastify/rate-limit');
|
||||
const config = require('./config');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const sharedCacheRoutes = require('./routes/shared-cache');
|
||||
const vaultRoutes = require('./routes/vault');
|
||||
|
||||
async function buildApp() {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: config.app.logLevel
|
||||
},
|
||||
bodyLimit: 1024 * 1024
|
||||
});
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
credentials: false
|
||||
});
|
||||
|
||||
await app.register(rateLimit, {
|
||||
max: 120,
|
||||
timeWindow: '1 minute'
|
||||
});
|
||||
|
||||
app.get('/', async function () {
|
||||
return {
|
||||
ok: true,
|
||||
service: 'magnet-cloud-cache-server',
|
||||
version: '0.1.0'
|
||||
};
|
||||
});
|
||||
|
||||
await app.register(authRoutes);
|
||||
await app.register(sharedCacheRoutes);
|
||||
await app.register(vaultRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const app = await buildApp();
|
||||
try {
|
||||
await app.listen({
|
||||
host: config.app.host,
|
||||
port: config.app.port
|
||||
});
|
||||
app.log.info('server started');
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
start();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildApp
|
||||
};
|
||||
78
server/src/redis.js
Normal file
78
server/src/redis.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const { createClient } = require('redis');
|
||||
|
||||
let clientPromise = null;
|
||||
|
||||
async function getRedisClient(config) {
|
||||
if (!config || !config.redis || !config.redis.enabled || !config.redis.url) {
|
||||
return null;
|
||||
}
|
||||
if (clientPromise) {
|
||||
return clientPromise;
|
||||
}
|
||||
clientPromise = (async function() {
|
||||
const client = createClient({ url: config.redis.url });
|
||||
client.on('error', function () {
|
||||
return null;
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
})().catch(function() {
|
||||
clientPromise = null;
|
||||
return null;
|
||||
});
|
||||
return clientPromise;
|
||||
}
|
||||
|
||||
async function getJson(config, key) {
|
||||
const client = await getRedisClient(config);
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const value = await client.get(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setJson(config, key, value, ttlSeconds) {
|
||||
const client = await getRedisClient(config);
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await client.set(key, JSON.stringify(value), {
|
||||
EX: Math.max(1, Number(ttlSeconds) || 60)
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function delByPattern(config, pattern) {
|
||||
const client = await getRedisClient(config);
|
||||
if (!client) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const keys = [];
|
||||
for await (const key of client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
|
||||
keys.push(key);
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await client.del(keys);
|
||||
}
|
||||
return keys.length;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRedisClient,
|
||||
getJson,
|
||||
setJson,
|
||||
delByPattern
|
||||
};
|
||||
169
server/src/routes/auth.js
Normal file
169
server/src/routes/auth.js
Normal file
@@ -0,0 +1,169 @@
|
||||
const db = require('../db');
|
||||
const {
|
||||
createAuthToken,
|
||||
getAuthContextFromToken,
|
||||
hashPassword,
|
||||
normalizeEmail,
|
||||
parseJsonMaybe,
|
||||
revokeAuthToken,
|
||||
verifyPassword
|
||||
} = require('../auth');
|
||||
|
||||
function normalizeString(value, limit) {
|
||||
return String(value || '').trim().slice(0, limit);
|
||||
}
|
||||
|
||||
function normalizeDeviceFingerprint(value) {
|
||||
return normalizeString(value, 191);
|
||||
}
|
||||
|
||||
function normalizeDeviceName(value) {
|
||||
return normalizeString(value, 191) || 'Chrome Extension';
|
||||
}
|
||||
|
||||
async function upsertDevice(userId, deviceName, deviceFingerprint) {
|
||||
if (!deviceFingerprint) {
|
||||
return;
|
||||
}
|
||||
await db.execute(
|
||||
'INSERT INTO devices (user_id, device_name, device_fingerprint, last_seen_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE device_name = VALUES(device_name), last_seen_at = CURRENT_TIMESTAMP',
|
||||
[userId, deviceName, deviceFingerprint]
|
||||
);
|
||||
}
|
||||
|
||||
async function routes(fastify) {
|
||||
fastify.post('/api/auth/register', async function (request, reply) {
|
||||
var body = request.body || {};
|
||||
var email = normalizeEmail(body.email);
|
||||
var password = String(body.password || '');
|
||||
var wrappedDek = normalizeString(body.wrappedDek, 5000);
|
||||
var kdfSalt = normalizeString(body.kdfSalt, 255);
|
||||
var keyVersion = Math.max(1, Number(body.keyVersion) || 1);
|
||||
var deviceName = normalizeDeviceName(body.deviceName);
|
||||
var deviceFingerprint = normalizeDeviceFingerprint(body.deviceFingerprint);
|
||||
var kdfParams = body.kdfParams && typeof body.kdfParams === 'object' ? body.kdfParams : {};
|
||||
|
||||
if (!email || email.indexOf('@') === -1) {
|
||||
reply.code(400);
|
||||
return { ok: false, error: '请检查邮箱格式后再试' };
|
||||
}
|
||||
if (password.length < 6) {
|
||||
reply.code(400);
|
||||
return { ok: false, error: '密码长度还不够,请再确认一下' };
|
||||
}
|
||||
if (!wrappedDek || !kdfSalt) {
|
||||
reply.code(400);
|
||||
return { ok: false, error: '当前初始化信息不完整,请稍后再试' };
|
||||
}
|
||||
|
||||
var existingRows = await db.query('SELECT id FROM users WHERE email = ? LIMIT 1', [email]);
|
||||
if (existingRows.length > 0) {
|
||||
reply.code(409);
|
||||
return { ok: false, error: '这个邮箱已经可以直接登录了' };
|
||||
}
|
||||
|
||||
var passwordHash = await hashPassword(password);
|
||||
var userResult = await db.execute(
|
||||
'INSERT INTO users (email, password_hash, status) VALUES (?, ?, 1)',
|
||||
[email, passwordHash]
|
||||
);
|
||||
var userId = Number(userResult.insertId);
|
||||
await db.execute(
|
||||
'INSERT INTO user_keyrings (user_id, wrapped_dek, kdf_salt, kdf_params, key_version) VALUES (?, ?, ?, ?, ?)',
|
||||
[userId, wrappedDek, kdfSalt, JSON.stringify(kdfParams), keyVersion]
|
||||
);
|
||||
await upsertDevice(userId, deviceName, deviceFingerprint);
|
||||
|
||||
var token = await createAuthToken(userId, deviceFingerprint);
|
||||
return {
|
||||
ok: true,
|
||||
token: token,
|
||||
user: { id: userId, email: email },
|
||||
keyring: {
|
||||
wrappedDek: wrappedDek,
|
||||
kdfSalt: kdfSalt,
|
||||
kdfParams: kdfParams,
|
||||
keyVersion: keyVersion
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
fastify.post('/api/auth/login', async function (request, reply) {
|
||||
var body = request.body || {};
|
||||
var email = normalizeEmail(body.email);
|
||||
var password = String(body.password || '');
|
||||
var deviceName = normalizeDeviceName(body.deviceName);
|
||||
var deviceFingerprint = normalizeDeviceFingerprint(body.deviceFingerprint);
|
||||
|
||||
var rows = await db.query(
|
||||
'SELECT u.id, u.email, u.password_hash, u.status, k.wrapped_dek, k.kdf_salt, k.kdf_params, k.key_version FROM users u LEFT JOIN user_keyrings k ON k.user_id = u.id WHERE u.email = ? LIMIT 1',
|
||||
[email]
|
||||
);
|
||||
if (!rows.length) {
|
||||
reply.code(401);
|
||||
return { ok: false, error: '账号信息没有对上,请再确认一下' };
|
||||
}
|
||||
|
||||
var row = rows[0];
|
||||
if (Number(row.status || 0) !== 1) {
|
||||
reply.code(403);
|
||||
return { ok: false, error: '当前账号暂时无法使用' };
|
||||
}
|
||||
|
||||
var valid = await verifyPassword(password, row.password_hash);
|
||||
if (!valid) {
|
||||
reply.code(401);
|
||||
return { ok: false, error: '账号信息没有对上,请再确认一下' };
|
||||
}
|
||||
|
||||
await upsertDevice(row.id, deviceName, deviceFingerprint);
|
||||
var token = await createAuthToken(row.id, deviceFingerprint);
|
||||
return {
|
||||
ok: true,
|
||||
token: token,
|
||||
user: { id: row.id, email: row.email },
|
||||
keyring: {
|
||||
wrappedDek: row.wrapped_dek,
|
||||
kdfSalt: row.kdf_salt,
|
||||
kdfParams: parseJsonMaybe(row.kdf_params) || {},
|
||||
keyVersion: Number(row.key_version || 1)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
fastify.get('/api/auth/me', async function (request, reply) {
|
||||
var header = request.headers.authorization || '';
|
||||
var match = String(header).match(/^Bearer\s+(.+)$/i);
|
||||
if (!match) {
|
||||
reply.code(401);
|
||||
return { ok: false, error: '先登录后就可以继续了' };
|
||||
}
|
||||
var authContext = await getAuthContextFromToken(match[1]);
|
||||
if (!authContext) {
|
||||
reply.code(401);
|
||||
return { ok: false, error: '登录状态需要重新确认一下' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
user: authContext.user,
|
||||
keyring: authContext.keyring
|
||||
};
|
||||
});
|
||||
|
||||
fastify.post('/api/auth/logout', async function (request, reply) {
|
||||
var header = request.headers.authorization || '';
|
||||
var match = String(header).match(/^Bearer\s+(.+)$/i);
|
||||
if (!match) {
|
||||
reply.code(400);
|
||||
return { ok: false, error: '当前登录信息还没带上' };
|
||||
}
|
||||
var authContext = await getAuthContextFromToken(match[1]);
|
||||
if (!authContext) {
|
||||
return { ok: true };
|
||||
}
|
||||
await revokeAuthToken(authContext.tokenHash);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = routes;
|
||||
477
server/src/routes/shared-cache.js
Normal file
477
server/src/routes/shared-cache.js
Normal file
@@ -0,0 +1,477 @@
|
||||
const db = require('../db');
|
||||
const config = require('../config');
|
||||
const { encryptJson, decryptJson, sha256 } = require('../crypto');
|
||||
const redisCache = require('../redis');
|
||||
|
||||
function requireSharedCacheWrite(request, reply) {
|
||||
const token = String(request.headers['x-shared-cache-write-token'] || '');
|
||||
if (!config.sharedCache.writeToken || token !== config.sharedCache.writeToken) {
|
||||
reply.code(401);
|
||||
return { ok: false, error: 'shared cache write unauthorized' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeThread(thread) {
|
||||
if (!thread || typeof thread !== 'object') return null;
|
||||
const forumKey = typeof thread.forumKey === 'string' ? thread.forumKey.trim() : '';
|
||||
const threadKey = typeof thread.threadKey === 'string' ? thread.threadKey.trim() : '';
|
||||
const url = typeof thread.url === 'string' ? thread.url.trim() : '';
|
||||
if (!forumKey || !threadKey) return null;
|
||||
return {
|
||||
forumKey,
|
||||
threadKey,
|
||||
url,
|
||||
title: typeof thread.title === 'string' ? thread.title : '',
|
||||
magnets: Array.isArray(thread.magnets) ? thread.magnets.filter(Boolean) : [],
|
||||
lastSeenAt: Number(thread.lastSeenAt) || Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCoverage(payload) {
|
||||
const forumKey = typeof payload.forumKey === 'string' ? payload.forumKey.trim() : '';
|
||||
const startPage = Math.max(1, Number(payload.startPage) || 1);
|
||||
const endPage = Math.max(startPage, Number(payload.endPage) || startPage);
|
||||
if (!forumKey) return null;
|
||||
return {
|
||||
forumKey,
|
||||
startPage,
|
||||
endPage,
|
||||
strategy: typeof payload.strategy === 'string' && payload.strategy ? payload.strategy : 'full_live',
|
||||
crawledAt: Number(payload.crawledAt) || Date.now(),
|
||||
threads: Array.isArray(payload.threads) ? payload.threads : []
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePageCoverage(payload) {
|
||||
const forumKey = typeof payload.forumKey === 'string' ? payload.forumKey.trim() : '';
|
||||
const page = Math.max(1, Number(payload.page) || 1);
|
||||
if (!forumKey) return null;
|
||||
return {
|
||||
forumKey,
|
||||
page,
|
||||
crawledAt: Number(payload.crawledAt) || Date.now(),
|
||||
threads: Array.isArray(payload.threads) ? payload.threads : []
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlanPayload(payload) {
|
||||
const forumKey = typeof payload.forumKey === 'string' ? payload.forumKey.trim() : '';
|
||||
const startPage = Math.max(1, Number(payload.startPage) || 1);
|
||||
const endPage = Math.max(startPage, Number(payload.endPage) || startPage);
|
||||
const frontRefreshPages = Math.max(0, Number(payload.frontRefreshPages) || 0);
|
||||
if (!forumKey) return null;
|
||||
return { forumKey, startPage, endPage, frontRefreshPages };
|
||||
}
|
||||
|
||||
function buildPlanCacheKey(payload) {
|
||||
return [
|
||||
'coverage-plan',
|
||||
payload.forumKey,
|
||||
payload.startPage,
|
||||
payload.endPage,
|
||||
payload.frontRefreshPages
|
||||
].join(':');
|
||||
}
|
||||
|
||||
async function getThreadMapForKeys(threadKeys) {
|
||||
if (!Array.isArray(threadKeys) || threadKeys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const placeholders = threadKeys.map(function () { return '?'; }).join(',');
|
||||
const rows = await db.query(
|
||||
'SELECT thread_key, payload_ciphertext, payload_iv, payload_tag FROM shared_thread_cache WHERE thread_key IN (' + placeholders + ')',
|
||||
threadKeys
|
||||
);
|
||||
return rows.map(function (row) {
|
||||
return decryptJson({
|
||||
ciphertext: row.payload_ciphertext,
|
||||
iv: row.payload_iv,
|
||||
tag: row.payload_tag
|
||||
});
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
async function buildHydratedCoverageBlock(record, clippedStart, clippedEnd) {
|
||||
const payload = decryptJson({
|
||||
ciphertext: record.payload_ciphertext,
|
||||
iv: record.payload_iv,
|
||||
tag: record.payload_tag
|
||||
});
|
||||
const threads = Array.isArray(payload.threads) ? payload.threads : [];
|
||||
if (!threads.length) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
forumKey: record.forum_key,
|
||||
startPage: clippedStart,
|
||||
endPage: clippedEnd,
|
||||
crawledAt: new Date(record.crawled_at).getTime(),
|
||||
strategy: record.strategy || 'full_live',
|
||||
frontRefreshPages: 0,
|
||||
threads: threads
|
||||
};
|
||||
}
|
||||
|
||||
async function buildCoveragePlan(payload) {
|
||||
const exactRows = await db.query(
|
||||
'SELECT forum_key, start_page, end_page, strategy, thread_count, crawled_at, payload_ciphertext, payload_iv, payload_tag FROM shared_coverage_cache WHERE forum_key = ? AND start_page = ? AND end_page = ? ORDER BY crawled_at DESC LIMIT 1',
|
||||
[payload.forumKey, payload.startPage, payload.endPage]
|
||||
);
|
||||
if (exactRows.length) {
|
||||
const exactCoverage = await buildHydratedCoverageBlock(exactRows[0], payload.startPage, payload.endPage);
|
||||
if (exactCoverage) {
|
||||
return { ok: true, exactCoverage, cachedBlocks: [], shiftedCoverage: null, source: 'server_exact' };
|
||||
}
|
||||
}
|
||||
|
||||
const pageRows = await db.query(
|
||||
'SELECT forum_key, page, crawled_at, payload_ciphertext, payload_iv, payload_tag FROM shared_page_cache WHERE forum_key = ? AND page BETWEEN ? AND ? ORDER BY page ASC',
|
||||
[payload.forumKey, payload.startPage, payload.endPage]
|
||||
);
|
||||
const pageBlocks = [];
|
||||
let currentBlock = null;
|
||||
pageRows.forEach(function (row) {
|
||||
const page = Number(row.page || 0);
|
||||
if (!currentBlock || page !== currentBlock.endPage + 1) {
|
||||
if (currentBlock) {
|
||||
pageBlocks.push(currentBlock);
|
||||
}
|
||||
currentBlock = { startPage: page, endPage: page, rows: [row] };
|
||||
return;
|
||||
}
|
||||
currentBlock.endPage = page;
|
||||
currentBlock.rows.push(row);
|
||||
});
|
||||
if (currentBlock) {
|
||||
pageBlocks.push(currentBlock);
|
||||
}
|
||||
|
||||
const hydratedPageBlocks = pageBlocks.map(function (block) {
|
||||
const merged = Object.create(null);
|
||||
const threads = [];
|
||||
let latestCrawledAt = 0;
|
||||
block.rows.forEach(function (row) {
|
||||
latestCrawledAt = Math.max(latestCrawledAt, new Date(row.crawled_at).getTime());
|
||||
const payloadData = decryptJson({
|
||||
ciphertext: row.payload_ciphertext,
|
||||
iv: row.payload_iv,
|
||||
tag: row.payload_tag
|
||||
});
|
||||
(Array.isArray(payloadData.threads) ? payloadData.threads : []).forEach(function (thread) {
|
||||
const key = String(thread.threadKey || thread.url || '');
|
||||
if (!key || merged[key]) {
|
||||
return;
|
||||
}
|
||||
merged[key] = true;
|
||||
threads.push(thread);
|
||||
});
|
||||
});
|
||||
return {
|
||||
forumKey: payload.forumKey,
|
||||
startPage: block.startPage,
|
||||
endPage: block.endPage,
|
||||
crawledAt: latestCrawledAt,
|
||||
strategy: 'assembled_pages',
|
||||
frontRefreshPages: 0,
|
||||
threads: threads
|
||||
};
|
||||
}).filter(function (block) {
|
||||
return Array.isArray(block.threads) && block.threads.length > 0;
|
||||
});
|
||||
|
||||
const coverageRows = await db.query(
|
||||
'SELECT forum_key, start_page, end_page, strategy, thread_count, crawled_at, payload_ciphertext, payload_iv, payload_tag FROM shared_coverage_cache WHERE forum_key = ? AND start_page <= ? AND end_page >= ? ORDER BY start_page ASC, end_page DESC',
|
||||
[payload.forumKey, payload.endPage, payload.startPage]
|
||||
);
|
||||
const hydratedCoverageBlocks = [];
|
||||
for (const row of coverageRows) {
|
||||
const clippedStart = Math.max(payload.startPage, Number(row.start_page || payload.startPage));
|
||||
const clippedEnd = Math.min(payload.endPage, Number(row.end_page || payload.endPage));
|
||||
if (clippedStart > clippedEnd) {
|
||||
continue;
|
||||
}
|
||||
const block = await buildHydratedCoverageBlock(row, clippedStart, clippedEnd);
|
||||
if (block) {
|
||||
hydratedCoverageBlocks.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
const allBlocks = hydratedPageBlocks.concat(hydratedCoverageBlocks).sort(function (a, b) {
|
||||
const startDiff = Number(a.startPage || 0) - Number(b.startPage || 0);
|
||||
if (startDiff !== 0) return startDiff;
|
||||
return Number(b.endPage || 0) - Number(a.endPage || 0);
|
||||
});
|
||||
|
||||
let shiftedCoverage = null;
|
||||
if (payload.startPage === 1 && payload.frontRefreshPages > 0) {
|
||||
const anchorRows = await db.query(
|
||||
'SELECT forum_key, start_page, end_page, strategy, thread_count, crawled_at, payload_ciphertext, payload_iv, payload_tag FROM shared_coverage_cache WHERE forum_key = ? AND start_page = 1 ORDER BY crawled_at DESC, end_page DESC LIMIT 1',
|
||||
[payload.forumKey]
|
||||
);
|
||||
if (anchorRows.length) {
|
||||
const anchor = await buildHydratedCoverageBlock(anchorRows[0], 1, Math.min(payload.endPage, Number(anchorRows[0].end_page || payload.endPage)));
|
||||
if (anchor) {
|
||||
shiftedCoverage = {
|
||||
forumKey: anchor.forumKey,
|
||||
sourceStartPage: anchor.startPage,
|
||||
sourceEndPage: Number(anchorRows[0].end_page || anchor.endPage),
|
||||
crawledAt: anchor.crawledAt,
|
||||
strategy: anchor.strategy,
|
||||
threads: anchor.threads,
|
||||
reusedStartPage: Math.min(payload.endPage, payload.startPage + payload.frontRefreshPages),
|
||||
reusedEndPage: Math.min(payload.endPage, Number(anchorRows[0].end_page || payload.endPage) + payload.frontRefreshPages)
|
||||
};
|
||||
if (shiftedCoverage.reusedStartPage > shiftedCoverage.reusedEndPage) {
|
||||
shiftedCoverage = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
exactCoverage: null,
|
||||
cachedBlocks: allBlocks,
|
||||
shiftedCoverage: shiftedCoverage,
|
||||
source: 'server_plan'
|
||||
};
|
||||
}
|
||||
|
||||
async function getCloudStats() {
|
||||
const countsRows = await db.query(
|
||||
"SELECT (SELECT COUNT(*) FROM shared_thread_cache) AS thread_count, (SELECT COUNT(*) FROM shared_thread_cache WHERE magnet_count > 0) AS magnet_thread_count, (SELECT COUNT(*) FROM shared_coverage_cache) AS coverage_count, (SELECT COUNT(*) FROM shared_page_cache) AS page_count, (SELECT COUNT(*) FROM vault_items) AS vault_count, (SELECT COUNT(*) FROM users) AS user_count"
|
||||
);
|
||||
const latestRows = await db.query(
|
||||
"SELECT (SELECT MAX(updated_at) FROM shared_thread_cache) AS latest_thread_time, (SELECT MAX(updated_at) FROM shared_coverage_cache) AS latest_coverage_time, (SELECT MAX(updated_at) FROM shared_page_cache) AS latest_page_time"
|
||||
);
|
||||
const sizeRows = await db.query(
|
||||
"SELECT table_name, table_rows, ROUND((data_length + index_length) / 1024 / 1024, 2) AS size_mb FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY (data_length + index_length) DESC"
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
counts: {
|
||||
threads: Number(countsRows[0].thread_count || 0),
|
||||
magnetThreads: Number(countsRows[0].magnet_thread_count || 0),
|
||||
coverages: Number(countsRows[0].coverage_count || 0),
|
||||
pages: Number(countsRows[0].page_count || 0),
|
||||
vaultItems: Number(countsRows[0].vault_count || 0),
|
||||
users: Number(countsRows[0].user_count || 0)
|
||||
},
|
||||
latest: {
|
||||
threads: latestRows[0].latest_thread_time,
|
||||
coverages: latestRows[0].latest_coverage_time,
|
||||
pages: latestRows[0].latest_page_time
|
||||
},
|
||||
tables: sizeRows.map(function(row) {
|
||||
return {
|
||||
tableName: row.table_name,
|
||||
rowCount: Number(row.table_rows || 0),
|
||||
sizeMb: Number(row.size_mb || 0)
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function routes(fastify) {
|
||||
fastify.get('/health', async function () {
|
||||
return { ok: true, service: 'magnet-cloud-cache-server' };
|
||||
});
|
||||
|
||||
fastify.get('/ready', async function (_, reply) {
|
||||
try {
|
||||
await db.ping();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
reply.code(500);
|
||||
return { ok: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get('/api/shared-cache/stats', async function () {
|
||||
return getCloudStats();
|
||||
});
|
||||
|
||||
fastify.post('/api/shared-cache/threads/lookup', async function (request) {
|
||||
const items = Array.isArray(request.body && request.body.threads) ? request.body.threads : [];
|
||||
const normalized = items.map(normalizeThread).filter(Boolean);
|
||||
if (normalized.length === 0) {
|
||||
return { ok: true, threads: [] };
|
||||
}
|
||||
|
||||
const forumKey = normalized[0].forumKey;
|
||||
const threadKeys = normalized.map(function (item) { return item.threadKey; });
|
||||
const placeholders = threadKeys.map(function () { return '?'; }).join(',');
|
||||
const rows = await db.query(
|
||||
'SELECT forum_key, thread_key, payload_ciphertext, payload_iv, payload_tag, updated_at FROM shared_thread_cache WHERE forum_key = ? AND thread_key IN (' + placeholders + ')',
|
||||
[forumKey].concat(threadKeys)
|
||||
);
|
||||
|
||||
const result = rows.map(function (row) {
|
||||
return decryptJson({
|
||||
ciphertext: row.payload_ciphertext,
|
||||
iv: row.payload_iv,
|
||||
tag: row.payload_tag
|
||||
});
|
||||
});
|
||||
|
||||
return { ok: true, threads: result };
|
||||
});
|
||||
|
||||
fastify.post('/api/shared-cache/threads/upsert', async function (request, reply) {
|
||||
const denied = requireSharedCacheWrite(request, reply);
|
||||
if (denied) return denied;
|
||||
const items = Array.isArray(request.body && request.body.threads) ? request.body.threads : [];
|
||||
const normalized = items.map(normalizeThread).filter(Boolean);
|
||||
let saved = 0;
|
||||
|
||||
for (const item of normalized) {
|
||||
const encrypted = encryptJson(item);
|
||||
await db.execute(
|
||||
'INSERT INTO shared_thread_cache (forum_key, thread_key, url_hash, title_hash, magnet_count, payload_ciphertext, payload_iv, payload_tag, payload_hash, last_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, FROM_UNIXTIME(? / 1000)) ON DUPLICATE KEY UPDATE magnet_count = IF(VALUES(magnet_count) > 0 AND VALUES(last_seen_at) >= last_seen_at, VALUES(magnet_count), magnet_count), payload_ciphertext = IF(VALUES(magnet_count) > 0 AND VALUES(last_seen_at) >= last_seen_at, VALUES(payload_ciphertext), payload_ciphertext), payload_iv = IF(VALUES(magnet_count) > 0 AND VALUES(last_seen_at) >= last_seen_at, VALUES(payload_iv), payload_iv), payload_tag = IF(VALUES(magnet_count) > 0 AND VALUES(last_seen_at) >= last_seen_at, VALUES(payload_tag), payload_tag), payload_hash = IF(VALUES(magnet_count) > 0 AND VALUES(last_seen_at) >= last_seen_at, VALUES(payload_hash), payload_hash), last_seen_at = GREATEST(last_seen_at, VALUES(last_seen_at)), updated_at = IF(VALUES(magnet_count) > 0 AND VALUES(last_seen_at) >= last_seen_at, CURRENT_TIMESTAMP, updated_at)',
|
||||
[
|
||||
item.forumKey,
|
||||
item.threadKey,
|
||||
sha256(item.url),
|
||||
sha256(item.title),
|
||||
item.magnets.length,
|
||||
encrypted.ciphertext,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
encrypted.payloadHash,
|
||||
item.lastSeenAt
|
||||
]
|
||||
);
|
||||
saved += 1;
|
||||
}
|
||||
|
||||
return { ok: true, savedCount: saved };
|
||||
});
|
||||
|
||||
fastify.post('/api/shared-cache/coverages/lookup', async function (request) {
|
||||
const payload = normalizeCoverage(request.body || {});
|
||||
if (!payload) {
|
||||
return { ok: true, coverage: null };
|
||||
}
|
||||
|
||||
const rows = await db.query(
|
||||
'SELECT payload_ciphertext, payload_iv, payload_tag FROM shared_coverage_cache WHERE forum_key = ? AND start_page = ? AND end_page = ? AND strategy = ? LIMIT 1',
|
||||
[payload.forumKey, payload.startPage, payload.endPage, payload.strategy]
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
return { ok: true, coverage: null };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
coverage: decryptJson({
|
||||
ciphertext: rows[0].payload_ciphertext,
|
||||
iv: rows[0].payload_iv,
|
||||
tag: rows[0].payload_tag
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
fastify.post('/api/shared-cache/coverages/upsert', async function (request, reply) {
|
||||
const denied = requireSharedCacheWrite(request, reply);
|
||||
if (denied) return denied;
|
||||
const payload = normalizeCoverage(request.body || {});
|
||||
if (!payload) {
|
||||
throw new Error('无效的 coverage 参数');
|
||||
}
|
||||
|
||||
const encrypted = encryptJson(payload);
|
||||
await db.execute(
|
||||
'INSERT INTO shared_coverage_cache (forum_key, start_page, end_page, strategy, thread_count, crawled_at, payload_ciphertext, payload_iv, payload_tag, payload_hash) VALUES (?, ?, ?, ?, ?, FROM_UNIXTIME(? / 1000), ?, ?, ?, ?) ON DUPLICATE KEY UPDATE thread_count = IF(VALUES(crawled_at) >= crawled_at, VALUES(thread_count), thread_count), crawled_at = GREATEST(crawled_at, VALUES(crawled_at)), payload_ciphertext = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_ciphertext), payload_ciphertext), payload_iv = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_iv), payload_iv), payload_tag = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_tag), payload_tag), payload_hash = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_hash), payload_hash), updated_at = IF(VALUES(crawled_at) >= crawled_at, CURRENT_TIMESTAMP, updated_at)',
|
||||
[
|
||||
payload.forumKey,
|
||||
payload.startPage,
|
||||
payload.endPage,
|
||||
payload.strategy,
|
||||
payload.threads.length,
|
||||
payload.crawledAt,
|
||||
encrypted.ciphertext,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
encrypted.payloadHash
|
||||
]
|
||||
);
|
||||
|
||||
redisCache.delByPattern(config, 'coverage-plan:' + payload.forumKey + ':*').catch(function () {});
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
fastify.post('/api/shared-cache/pages/lookup', async function (request) {
|
||||
const payload = normalizePageCoverage(request.body || {});
|
||||
if (!payload) {
|
||||
return { ok: true, coverage: null };
|
||||
}
|
||||
|
||||
const rows = await db.query(
|
||||
'SELECT payload_ciphertext, payload_iv, payload_tag FROM shared_page_cache WHERE forum_key = ? AND page = ? LIMIT 1',
|
||||
[payload.forumKey, payload.page]
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
return { ok: true, coverage: null };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
coverage: decryptJson({
|
||||
ciphertext: rows[0].payload_ciphertext,
|
||||
iv: rows[0].payload_iv,
|
||||
tag: rows[0].payload_tag
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
fastify.post('/api/shared-cache/pages/upsert', async function (request, reply) {
|
||||
const denied = requireSharedCacheWrite(request, reply);
|
||||
if (denied) return denied;
|
||||
const payload = normalizePageCoverage(request.body || {});
|
||||
if (!payload) {
|
||||
throw new Error('无效的 page coverage 参数');
|
||||
}
|
||||
|
||||
const encrypted = encryptJson(payload);
|
||||
await db.execute(
|
||||
'INSERT INTO shared_page_cache (forum_key, page, thread_count, crawled_at, payload_ciphertext, payload_iv, payload_tag, payload_hash) VALUES (?, ?, ?, FROM_UNIXTIME(? / 1000), ?, ?, ?, ?) ON DUPLICATE KEY UPDATE thread_count = IF(VALUES(crawled_at) >= crawled_at, VALUES(thread_count), thread_count), crawled_at = GREATEST(crawled_at, VALUES(crawled_at)), payload_ciphertext = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_ciphertext), payload_ciphertext), payload_iv = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_iv), payload_iv), payload_tag = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_tag), payload_tag), payload_hash = IF(VALUES(crawled_at) >= crawled_at, VALUES(payload_hash), payload_hash), updated_at = IF(VALUES(crawled_at) >= crawled_at, CURRENT_TIMESTAMP, updated_at)',
|
||||
[
|
||||
payload.forumKey,
|
||||
payload.page,
|
||||
payload.threads.length,
|
||||
payload.crawledAt,
|
||||
encrypted.ciphertext,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
encrypted.payloadHash
|
||||
]
|
||||
);
|
||||
|
||||
redisCache.delByPattern(config, 'coverage-plan:' + payload.forumKey + ':*').catch(function () {});
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
fastify.post('/api/shared-cache/coverages/plan', async function (request) {
|
||||
const payload = normalizePlanPayload(request.body || {});
|
||||
const cacheKey = payload ? buildPlanCacheKey(payload) : '';
|
||||
let cachedPlan = null;
|
||||
if (!payload) {
|
||||
return { ok: false, error: 'invalid coverage plan payload' };
|
||||
}
|
||||
cachedPlan = await redisCache.getJson(config, cacheKey);
|
||||
if (cachedPlan) {
|
||||
return Object.assign({}, cachedPlan, { source: 'redis_plan' });
|
||||
}
|
||||
const plan = await buildCoveragePlan(payload);
|
||||
await redisCache.setJson(config, cacheKey, plan, 90);
|
||||
return plan;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = routes;
|
||||
96
server/src/routes/vault.js
Normal file
96
server/src/routes/vault.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const db = require('../db');
|
||||
const { requireAuth } = require('../auth');
|
||||
|
||||
function normalizeVaultItem(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null;
|
||||
}
|
||||
var itemType = String(item.itemType || '').trim().slice(0, 64);
|
||||
var itemKey = String(item.itemKey || '').trim().slice(0, 191);
|
||||
var payloadCiphertext = String(item.payloadCiphertext || '').trim();
|
||||
var payloadIv = String(item.payloadIv || '').trim().slice(0, 128);
|
||||
var payloadTag = String(item.payloadTag || '').trim().slice(0, 128);
|
||||
var payloadHash = String(item.payloadHash || '').trim().slice(0, 64);
|
||||
var keyVersion = Math.max(1, Number(item.keyVersion) || 1);
|
||||
|
||||
if (!itemType || !itemKey || !payloadCiphertext || !payloadIv || !payloadTag || !payloadHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
itemType: itemType,
|
||||
itemKey: itemKey,
|
||||
payloadCiphertext: payloadCiphertext,
|
||||
payloadIv: payloadIv,
|
||||
payloadTag: payloadTag,
|
||||
payloadHash: payloadHash,
|
||||
keyVersion: keyVersion
|
||||
};
|
||||
}
|
||||
|
||||
async function routes(fastify) {
|
||||
fastify.addHook('preHandler', requireAuth);
|
||||
|
||||
fastify.post('/api/vault/push', async function (request, reply) {
|
||||
var items = Array.isArray(request.body && request.body.items) ? request.body.items : [];
|
||||
var normalized = items.map(normalizeVaultItem).filter(Boolean);
|
||||
var index = 0;
|
||||
var item = null;
|
||||
if (normalized.length === 0) {
|
||||
reply.code(400);
|
||||
return { ok: false, error: '没有可保存的保险柜项目' };
|
||||
}
|
||||
|
||||
for (index = 0; index < normalized.length; index++) {
|
||||
item = normalized[index];
|
||||
await db.execute(
|
||||
'INSERT INTO vault_items (user_id, item_type, item_key, payload_ciphertext, payload_iv, payload_tag, payload_hash, key_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE payload_ciphertext = VALUES(payload_ciphertext), payload_iv = VALUES(payload_iv), payload_tag = VALUES(payload_tag), payload_hash = VALUES(payload_hash), key_version = VALUES(key_version), updated_at = CURRENT_TIMESTAMP',
|
||||
[
|
||||
request.authContext.user.id,
|
||||
item.itemType,
|
||||
item.itemKey,
|
||||
item.payloadCiphertext,
|
||||
item.payloadIv,
|
||||
item.payloadTag,
|
||||
item.payloadHash,
|
||||
item.keyVersion
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true, savedCount: normalized.length };
|
||||
});
|
||||
|
||||
fastify.post('/api/vault/pull', async function (request) {
|
||||
var itemTypes = Array.isArray(request.body && request.body.itemTypes) ? request.body.itemTypes.map(function (itemType) {
|
||||
return String(itemType || '').trim().slice(0, 64);
|
||||
}).filter(Boolean) : [];
|
||||
|
||||
var sql = 'SELECT item_type, item_key, payload_ciphertext, payload_iv, payload_tag, payload_hash, key_version, updated_at FROM vault_items WHERE user_id = ?';
|
||||
var params = [request.authContext.user.id];
|
||||
if (itemTypes.length > 0) {
|
||||
sql += ' AND item_type IN (' + itemTypes.map(function () { return '?'; }).join(',') + ')';
|
||||
params = params.concat(itemTypes);
|
||||
}
|
||||
sql += ' ORDER BY updated_at DESC';
|
||||
|
||||
var rows = await db.query(sql, params);
|
||||
return {
|
||||
ok: true,
|
||||
items: rows.map(function (row) {
|
||||
return {
|
||||
itemType: row.item_type,
|
||||
itemKey: row.item_key,
|
||||
payloadCiphertext: row.payload_ciphertext,
|
||||
payloadIv: row.payload_iv,
|
||||
payloadTag: row.payload_tag,
|
||||
payloadHash: row.payload_hash,
|
||||
keyVersion: Number(row.key_version || 1),
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = routes;
|
||||
Reference in New Issue
Block a user