diff --git a/README.md b/README.md index 57816fc..cd0afb2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # MoonTV
- LibreTV Logo + MoonTV Logo
-> 🎬 **MoonTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、本地/云端存储,让你可以随时随地畅享海量免费影视内容。 +> 🎬 **MoonTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
@@ -20,14 +20,15 @@ ## ✨ 功能特性 -- 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果。 +- 🔍 **多源聚合搜索**:一次搜索立刻返回全源结果。 - 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。 - ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。 -- ❤️ **收藏 + 继续观看**:支持 Redis/D1/Upstash 存储,多端同步进度。 +- ❤️ **收藏 + 继续观看**:支持 Kvrocks/Redis/Upstash 存储,多端同步进度。 - 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。 - 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。 -- 🚀 **极简部署**:一条 Docker 命令即可将完整服务跑起来,或免费部署到 Vercel、Netlify 和 ~~Cloudflare~~。 -- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性) +- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)。 + +### 注意:部署后项目为空壳项目,无内置播放源和直播源,需要自行收集
点击查看项目截图 @@ -36,14 +37,15 @@ 项目截图
+### 请不要在 B站、小红书、微信公众号、抖音、今日头条或其他中国大陆社交平台发布视频或文章宣传本项目,不授权任何“科技周刊/月刊”类项目或站点收录本项目。 + ## 🗺 目录 - [技术栈](#技术栈) - [部署](#部署) -- [Docker Compose 最佳实践](#Docker-Compose-最佳实践) +- [配置文件](#配置文件) +- [自动更新](#自动更新) - [环境变量](#环境变量) -- [配置说明](#配置说明) -- [管理员配置](#管理员配置) - [AndroidTV 使用](#AndroidTV-使用) - [Roadmap](#roadmap) - [安全与隐私提醒](#安全与隐私提醒) @@ -59,148 +61,55 @@ | 语言 | TypeScript 4 | | 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) | | 代码质量 | ESLint · Prettier · Jest | -| 部署 | Docker · Vercel · CloudFlare pages | +| 部署 | Docker | ## 部署 -本项目**支持 Vercel、Docker、Netlify 和 ~~Cloudflare~~** 部署。 +本项目**仅支持 Docker 或其他基于 Docker 的平台** 部署。 -存储支持矩阵 +### Kvrocks 存储(推荐) -| | Docker | Vercel | Netlify | ~~Cloudflare~~ | -| :---------------: | :----: | :----: | :-----: | :------------: | -| localstorage | ✅ | ✅ | ✅ | ✅ | -| 原生 redis | ✅ | | | | -| ~~Cloudflare D1~~ | | | | ✅ | -| Upstash Redis | ☑️ | ✅ | ✅ | ☑️ | - -✅:经测试支持 - -☑️:理论上支持,未测试 - -除 localstorage 方式外,其他方式都支持多账户、记录同步和管理页面 - -### Vercel 部署 - -#### 普通部署(localstorage) - -1. **Fork** 本仓库到你的 GitHub 账户。 -2. 登陆 [Vercel](https://vercel.com/),点击 **Add New → Project**,选择 Fork 后的仓库。 -3. 设置 PASSWORD 环境变量。 -4. 保持默认设置完成首次部署。 -5. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。 -6. 每次 Push 到 `main` 分支将自动触发重新构建。 - -部署完成后即可通过分配的域名访问,也可以绑定自定义域名。 - -#### Upstash Redis 支持 - -0. 完成普通部署并成功访问。 -1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。 -2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN** -3. 返回你的 Vercel 项目,新增环境变量 **UPSTASH_URL 和 UPSTASH_TOKEN**,值为第二步复制的 endpoint 和 token -4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 **upstash**;设置 USERNAME 和 PASSWORD 作为站长账号 -5. 重试部署 - -### Netlify 部署 - -#### 普通部署(localstorage) - -1. **Fork** 本仓库到你的 GitHub 账户。 -2. 登陆 [Netlify](https://www.netlify.com/),点击 **Add New project → Importing an existing project**,授权 Github,选择 Fork 后的仓库。 -3. 设置 PASSWORD 环境变量。 -4. 保持默认设置完成首次部署。 -5. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。 -6. 每次 Push 到 `main` 分支将自动触发重新构建。 - -部署完成后即可通过分配的域名访问,也可以绑定自定义域名。 - -#### Upstash Redis 支持 - -0. 完成普通部署并成功访问。 -1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。 -2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN** -3. 返回你的 Netlify 项目,**Project Configuration → Environment variables** 新增环境变量 **UPSTASH_URL 和 UPSTASH_TOKEN**,值为第二步复制的 endpoint 和 token -4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 **upstash**;设置 USERNAME 和 PASSWORD 作为站长账号 -5. 重试部署 - -### Cloudflare 部署(**不支持,详情请看置顶 issue**) - -~~**Cloudflare Pages 的环境变量尽量设置为密钥而非文本**~~ - -#### ~~普通部署(localstorage)~~ - -~~1. **Fork** 本仓库到你的 GitHub 账户。~~ -~~2. 登陆 [Cloudflare](https://cloudflare.com),点击 **计算(Workers)-> Workers 和 Pages**,点击创建~~ -~~3. 选择 Pages,导入现有的 Git 存储库,选择 Fork 后的仓库~~ -~~4. 构建命令填写 **pnpm install --frozen-lockfile && pnpm run pages:build**,预设框架为无,**构建输出目录**为 `.vercel/output/static`~~ -~~5. 保持默认设置完成首次部署。进入设置,将兼容性标志设置为 `nodejs_compat`,无需选择,直接粘贴~~ -~~6. 首次部署完成后进入设置,新增 PASSWORD 密钥(变量和机密下),而后重试部署。~~ -~~7. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。~~ -~~8. 每次 Push 到 `main` 分支将自动触发重新构建。~~ - -#### ~~D1 支持~~ - -~~0. 完成普通部署并成功访问~~ -~~1. 点击 **存储和数据库 -> D1 SQL 数据库**,创建一个新的数据库,名称随意~~ -~~2. 进入刚创建的数据库,点击左上角的 Explore Data,将[D1 初始化](D1初始化.md) 中的内容粘贴到 Query 窗口后点击 **Run All**,等待运行完成~~ -~~3. 返回你的 pages 项目,进入 **设置 -> 绑定**,添加绑定 D1 数据库,选择你刚创建的数据库,变量名称填 **DB**~~ -~~4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 **d1**;设置 USERNAME 和 PASSWORD 作为站长账号~~ -~~5. 重试部署~~ - -### Docker 部署 - -#### 1. 直接运行(最简单,localstorage) - -```bash -# 拉取预构建镜像 -# 推荐使用具体版本号标签,确保稳定性 -docker pull ghcr.io/lunatechlab/moontv:1.0.4 -# 或拉取最新版本 -docker pull ghcr.io/lunatechlab/moontv:latest - -# 运行容器 -# -d: 后台运行 -p: 映射端口 3000 -> 3000 -docker run -d --name moontv -p 3000:3000 --env PASSWORD=your_password ghcr.io/lunatechlab/moontv:latest -``` - -#### 可用标签 - -- `ghcr.io/lunatechlab/moontv:1.0.4` - 具体版本号,推荐用于生产环境 -- `ghcr.io/lunatechlab/moontv:latest` - 最新版本,可能包含最新功能但也可能有未测试的变化 -- `ghcr.io/lunatechlab/moontv:pr-{number}` - PR 构建版本,用于测试新功能 - -访问 `http://服务器 IP:3000` 即可。(需自行到服务器控制台放通 `3000` 端口) - -## Docker Compose 最佳实践 - -若你使用 docker compose 部署,以下是一些 compose 示例 - -### local storage 版本 - -```yaml +```yml services: moontv-core: - image: ghcr.io/lunatechlab/moontv:latest + image: ghcr.io/moontechlab/lunatv:latest container_name: moontv-core - restart: unless-stopped + restart: on-failure ports: - '3000:3000' environment: - - PASSWORD=your_password - # 如需自定义配置,可挂载文件 - # volumes: - # - ./config.json:/app/config.json:ro + - USERNAME=admin + - PASSWORD=admin_password + - NEXT_PUBLIC_STORAGE_TYPE=kvrocks + - KVROCKS_URL=redis://moontv-kvrocks:6666 + - AUTH_TOKEN=授权码 + networks: + - moontv-network + depends_on: + - moontv-kvrocks + moontv-kvrocks: + image: apache/kvrocks + container_name: moontv-kvrocks + restart: unless-stopped + volumes: + - kvrocks-data:/var/lib/kvrocks + networks: + - moontv-network +networks: + moontv-network: + driver: bridge +volumes: + kvrocks-data: ``` -### Redis 版本(推荐,多账户数据隔离,跨设备同步) +### Redis 存储(有一定的丢数据风险) -```yaml +```yml services: moontv-core: - image: ghcr.io/lunatechlab/moontv:latest + image: ghcr.io/moontechlab/lunatv:latest container_name: moontv-core - restart: unless-stopped + restart: on-failure ports: - '3000:3000' environment: @@ -208,88 +117,61 @@ services: - PASSWORD=admin_password - NEXT_PUBLIC_STORAGE_TYPE=redis - REDIS_URL=redis://moontv-redis:6379 - - NEXT_PUBLIC_ENABLE_REGISTER=true + - AUTH_TOKEN=授权码 networks: - moontv-network depends_on: - moontv-redis - # 如需自定义配置,可挂载文件 - # volumes: - # - ./config.json:/app/config.json:ro moontv-redis: image: redis:alpine container_name: moontv-redis restart: unless-stopped networks: - moontv-network - # 如需持久化 - # volumes: - # - ./data:/data + # 请开启持久化,否则升级/重启后数据丢失 + volumes: + - ./data:/data networks: moontv-network: driver: bridge ``` -## 自动同步最近更改 +### Upstash 存储 -建议在 fork 的仓库中启用本仓库自带的 GitHub Actions 自动同步功能(见 `.github/workflows/sync.yml`)。 +1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。 +2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN** +3. 使用如下 docker compose +```yml +services: + moontv-core: + image: ghcr.io/moontechlab/lunatv:latest + container_name: moontv-core + restart: on-failure + ports: + - '3000:3000' + environment: + - USERNAME=admin + - PASSWORD=admin_password + - NEXT_PUBLIC_STORAGE_TYPE=upstash + - UPSTASH_URL=上面 https 开头的 HTTPS ENDPOINT + - UPSTASH_TOKEN=上面的 TOKEN + - AUTH_TOKEN=授权码 +``` -如需手动同步主仓库更新,也可以使用 GitHub 官方的 [Sync fork](https://docs.github.com/cn/github/collaborating-with-issues-and-pull-requests/syncing-a-fork) 功能。 +## 配置文件 -## 环境变量 +完成部署后为空壳应用,无播放源,需要站长在管理后台的配置文件设置中填写配置文件(后续会支持订阅) -| 变量 | 说明 | 可选值 | 默认值 | -| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| USERNAME | 非 localstorage 部署时的管理员账号 | 任意字符串 | (空) | -| PASSWORD | 非 localstorage 部署时为管理员密码 | 任意字符串 | (空) | -| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV | -| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 | -| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage、redis、d1、upstash | localstorage | -| REDIS_URL | redis 连接 url | 连接 url | 空 | -| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 | -| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 | -| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在非 localstorage 部署时生效 | true / false | false | -| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 | -| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct | -| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) | -| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct | -| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) | -| direct | -| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false | -| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true | - -NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释: - -- direct: 由服务器直接请求豆瓣源站 -- melody-cdn-sharon: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [旋律](https://github.com/JohnsonRan) 搭建,并由 Sharon cdn 提供加速 -- cors-proxy-zwei: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 由 [Zwei](https://github.com/bestzwei) 搭建 -- cmliussss-cdn-tencent: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速 -- cmliussss-cdn-ali: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速 -- cors-anywhere: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 为公共服务 [cors-anywhere](https://cors-anywhere.com),限制每分钟 20 次请求 -- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_PROXY 定义 - -NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释: - -- direct:由浏览器直接请求豆瓣分配的默认图片域名 -- server:由服务器代理请求豆瓣分配的默认图片域名 -- img3:由浏览器请求豆瓣官方的精品 cdn(阿里云) -- melody-cdn-sharon: 由浏览器请求豆瓣 CDN,该 CDN 由 [旋律](https://github.com/JohnsonRan) 搭建,并由 Sharon cdn 提供加速 -- cmliussss-cdn-tencent:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速 -- cmliussss-cdn-ali:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速 -- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_IMAGE_PROXY 定义 - -## 配置说明 - -所有可自定义项集中在根目录的 `config.json` 中: +配置文件示例如下: ```json { "cache_time": 7200, "api_site": { "dyttzy": { - "api": "http://caiji.dyttzyapi.com/api.php/provide/vod", - "name": "电影天堂资源", - "detail": "http://caiji.dyttzyapi.com" + "api": "http://xxx.com/api.php/provide/vod", + "name": "示例资源", + "detail": "http://xxx.com" } // ...更多站点 }, @@ -323,39 +205,62 @@ custom_category 支持的自定义分类已知如下: MoonTV 支持标准的苹果 CMS V10 API 格式。 -修改后 **无需重新构建**,服务会在启动时读取一次。 +## 自动更新 -## 管理员配置 +可借助 [watchtower](https://github.com/containrrr/watchtower) 自动更新镜像容器 -**该特性目前仅支持通过非 localstorage 存储的部署方式使用** +dockge/komodo 等 docker compose UI 也有自动更新功能 -支持在运行时动态变更服务配置 +## 环境变量 -设置环境变量 USERNAME 和 PASSWORD 即为站长用户,站长可设置用户为管理员 +| 变量 | 说明 | 可选值 | 默认值 | +| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 | +| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 | +| SITE_BASE | 站点 url | 形如 https://example.com | 空 | +| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV | +| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 | +| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 | +| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 | +| REDIS_URL | redis 连接 url | 连接 url | 空 | +| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 | +| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 | +| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 | +| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct | +| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) | +| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct | +| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) | +| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false | +| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true | -站长或管理员访问 `/admin` 即可进行管理员配置 +NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释: + +- direct: 由服务器直接请求豆瓣源站 +- cors-proxy-zwei: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 由 [Zwei](https://github.com/bestzwei) 搭建 +- cmliussss-cdn-tencent: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速 +- cmliussss-cdn-ali: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速 +- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_PROXY 定义 + +NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释: + +- direct:由浏览器直接请求豆瓣分配的默认图片域名 +- server:由服务器代理请求豆瓣分配的默认图片域名 +- img3:由浏览器请求豆瓣官方的精品 cdn(阿里云) +- cmliussss-cdn-tencent:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速 +- cmliussss-cdn-ali:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速 +- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_IMAGE_PROXY 定义 ## AndroidTV 使用 目前该项目可以配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用,可以直接作为 OrionTV 后端 -暂时收藏夹与播放记录和网页端隔离,后续会支持同步用户数据 - -## Roadmap - -- [x] 深色模式 -- [x] 持久化存储 -- [x] 多账户 +已实现播放记录和网页端同步 ## 安全与隐私提醒 ### 请设置密码保护并关闭公网注册 -为了您的安全和避免潜在的法律风险,我们要求在部署时设置密码保护并**强烈建议关闭公网注册**: - -- **避免公开访问**:不设置密码的实例任何人都可以访问,可能被恶意利用 -- **防范版权风险**:公开的视频搜索服务可能面临版权方的投诉举报 -- **保护个人隐私**:设置密码可以限制访问范围,保护您的使用记录 +为了您的安全和避免潜在的法律风险,我们要求在部署时**强烈建议关闭公网注册**: ### 部署要求 @@ -369,6 +274,7 @@ MoonTV 支持标准的苹果 CMS V10 API 格式。 - 请勿将部署的实例用于商业用途或公开服务 - 如因公开分享导致的任何法律问题,用户需自行承担责任 - 项目开发者不对用户的使用行为承担任何法律责任 +- 本项目不在中国大陆地区提供服务。如有该项目在向中国大陆地区提供服务,属个人行为。在该地区使用所产生的法律风险及责任,属于用户个人行为,与本项目无关,须自行承担全部责任。特此声明 ## License @@ -384,8 +290,6 @@ MoonTV 支持标准的苹果 CMS V10 API 格式。 - [CMLiussss](https://github.com/cmliu) — 提供豆瓣 CDN 服务 - 感谢所有提供免费影视接口的站点。 ---- +## Star History -## Star 趋势 - -[![Stargazers over time](https://starchart.cc/LunaTechLab/MoonTV.svg?variant=adaptive)](https://starchart.cc/LunaTechLab/MoonTV) +[![Star History Chart](https://api.star-history.com/svg?repos=MoonTechLab/LunaTV&type=Date)](https://www.star-history.com/#MoonTechLab/LunaTV&Date) diff --git a/next.config.js b/next.config.js index 93c3b13..eef0435 100644 --- a/next.config.js +++ b/next.config.js @@ -1,7 +1,5 @@ /** @type {import('next').NextConfig} */ /* eslint-disable @typescript-eslint/no-var-requires */ -const WebpackObfuscator = require('webpack-obfuscator'); -const obfuscationConfig = require('./obfuscation.config.js'); const nextConfig = { output: 'standalone', @@ -31,17 +29,7 @@ const nextConfig = { ], }, - webpack(config, { dev, isServer }) { - // 只在生产环境启用混淆 - if (!dev && process.env.NODE_ENV === 'production') { - // 服务端代码混淆配置 - if (isServer) { - config.plugins.push( - new WebpackObfuscator(obfuscationConfig.obfuscator) - ); - } - } - + webpack(config) { // Grab the existing rule that handles SVG imports const fileLoaderRule = config.module.rules.find((rule) => rule.test?.test?.('.svg') diff --git a/obfuscation.config.js b/obfuscation.config.js deleted file mode 100644 index 720819a..0000000 --- a/obfuscation.config.js +++ /dev/null @@ -1,300 +0,0 @@ -// 代码混淆配置文件 - 专门针对 Next.js 项目优化 -module.exports = { - // Webpack Obfuscator 配置(包含压缩功能) - obfuscator: { - // ========== 基础压缩选项 ========== - compact: true, // 压缩代码(移除空格、换行等) - simplify: true, // 简化代码(合并变量、优化表达式) - - // ========== 字符串混淆选项(保守配置) ========== - splitStrings: true, // 分割字符串 - splitStringsChunkLength: 8, // 减少字符串分割块长度 - stringArray: true, // 字符串数组 - stringArrayEncoding: ['base64'], // 字符串数组编码 - stringArrayThreshold: 0.3, // 大幅降低字符串数组阈值,只混淆长字符串 - stringArrayWrappersCount: 1, // 减少字符串数组包装器数量 - stringArrayWrappersType: 'function', // 使用函数包装器 - stringArrayWrappersParameters: ['index'], // 包装器参数 - - // ========== 标识符混淆选项(保守配置) ========== - identifierNamesGenerator: 'hexadecimal', // 标识符名称生成器 - renameGlobals: false, // 不重命名全局变量 - identifiersPrefix: '_0x', // 标识符前缀 - identifiersDictionary: [], // 空字典,使用默认生成 - - // ========== 完全禁用复杂混淆 ========== - controlFlowFlattening: false, // 禁用控制流扁平化 - deadCodeInjection: false, // 禁用死代码注入 - debugProtection: false, // 调试保护(生产环境建议关闭) - debugProtectionInterval: 0, // 调试保护间隔 - numbersToExpressions: false, // 禁用数字转表达式 - selfDefending: false, // 禁用自我保护 - transformObjectKeys: false, // 禁用转换对象键 - unicodeEscapeSequence: false, // Unicode 转义序列 - - // ========== 日志和调试选项 ========== - disableConsoleOutput: false, // 保留控制台输出 - log: false, // 不输出混淆日志 - - // ========== 高级选项 ========== - sourceMap: false, // 不生成 source map - target: 'node', // 目标环境为 Node.js - seed: 0, // 随机种子 - - // ========== 保留重要标识符(扩展版) ========== - reservedNames: [ - "require", - "module", - "exports", - "__dirname", - "__filename", - "global", - "process", - "Buffer", - "setTimeout", - "setInterval", - "clearTimeout", - "clearInterval", - "getStaticProps", - "getStaticPaths", - "getServerSideProps", - "getInitialProps", - "reportWebVitals", - "db", - "client", - "query", - "execute", - "connect", - "disconnect", - "redis", - "kvrocks", - "upstash", - "mongo", - "postgres", - "mysql", - "GET", - "POST", - "PUT", - "DELETE", - "PATCH", - "OPTIONS", - "HEAD", - "Request", - "Response", - "NextRequest", - "NextResponse", - "Error", - "TypeError", - "ReferenceError", - "SyntaxError", - "RangeError", - "EvalError", - "URIError", - "AggregateError", - "init", - "setup", - "config", - "start", - "stop", - "run", - "main", - "handler", - "middleware", - "auth", - "validate", - "parse", - "serialize", - "console", - "log", - "error", - "warn", - "info", - "debug", - "trace", - "assert", - "clear", - "count", - "countReset", - "group", - "groupCollapsed", - "groupEnd", - "table", - "time", - "timeEnd", - "timeLog", - "profile", - "profileEnd", - "logger", - "logging", - "logLevel", - "logInfo", - "logError", - "logWarn", - "logDebug", - "logTrace", - "logFatal", - "logCritical", - "next", - "react", - "react-dom", - "next-pwa", - "next-themes", - "import", - "export", - "from", - "as", - "webpack", - "webpackChunk", - "webpackChunkName", - "chunk", - "chunks", - "chunkName", - "chunkFilename", - "entry", - "entries", - "entrypoint", - "entrypoints", - "modules", - "moduleId", - "moduleIds", - "resolve", - "resolver", - "resolveLoader", - "resolveModules", - "externals", - "externalsType", - "externalsPresets", - "output", - "outputPath", - "outputFilename", - "outputChunkFilename", - "optimization", - "minimize", - "minimizer", - "splitChunks", - "plugins", - "plugin", - "apply", - "compiler", - "compilation", - "loader", - "loaders", - "use", - "test", - "include", - "exclude", - "rules", - "rule", - "oneOf", - "resource", - "resourceQuery", - "issuer", - "issuerLayer", - "sideEffects", - "parser", - "generator", - "define", - "defined", - "definition", - "definitions", - "factory", - "factories", - "getter", - "getters", - "setter", - "setters", - "amd", - "umd", - "commonjs", - "esm", - "es6", - "es2015", - "es2020", - "babel", - "babelrc", - "tsconfig", - "jest", - "eslint", - "prettier", - "rollup", - "vite", - "parcel", - "gulp", - "grunt" - ], - - // ========== 保留重要字符串(扩展版) ========== - reservedStrings: [ - "api", - "admin", - "user", - "auth", - "login", - "logout", - "register", - "search", - "live", - "douban", - "favorites", - "playrecords", - "proxy", - "database", - "connection", - "result", - "success", - "setting", - "option", - "parameter", - "environment", - "file", - "path", - "directory", - "folder", - "extension", - "logger", - "logging", - "fatal", - "critical", - "output", - "message", - "next", - "react", - "react-dom", - "next-pwa", - "next-themes", - "webpack", - "chunk", - "module", - "entry", - "loader", - "plugin", - "compiler", - "compilation", - "require", - "import", - "export", - "default", - "from", - "as", - "use", - "test", - "include", - "define", - "factory", - "amd", - "umd", - "commonjs", - "esm", - "babel", - "typescript", - "jest", - "eslint", - "prettier", - "rollup", - "vite", - "parcel", - "gulp", - "grunt" - ] - } -}; diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a0d7342..3c5cce0 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3387,9 +3387,9 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | Announcement: '', SearchDownstreamMaxPage: 1, SiteInterfaceCacheTime: 7200, - DoubanProxyType: 'melody-cdn-sharon', + DoubanProxyType: 'cmliussss-cdn-tencent', DoubanProxy: '', - DoubanImageProxyType: 'melody-cdn-sharon', + DoubanImageProxyType: 'cmliussss-cdn-tencent', DoubanImageProxy: '', DisableYellowFilter: false, FluidSearch: true, @@ -3403,7 +3403,6 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | // 豆瓣数据源选项 const doubanDataSourceOptions = [ { value: 'direct', label: '直连(服务器直接请求豆瓣)' }, - { value: 'melody-cdn-sharon', label: '豆瓣 CDN By 旋律(Sharon CDN)' }, { value: 'cors-proxy-zwei', label: 'Cors Proxy By Zwei' }, { value: 'cmliussss-cdn-tencent', @@ -3418,7 +3417,6 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | { value: 'direct', label: '直连(浏览器直接请求豆瓣)' }, { value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' }, { value: 'img3', label: '豆瓣官方精品 CDN(阿里云)' }, - { value: 'melody-cdn-sharon', label: '豆瓣 CDN By 旋律(Sharon CDN)' }, { value: 'cmliussss-cdn-tencent', label: '豆瓣 CDN By CMLiussss(腾讯云)', @@ -3430,11 +3428,6 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | // 获取感谢信息 const getThanksInfo = (dataSource: string) => { switch (dataSource) { - case 'melody-cdn-sharon': - return { - text: 'Thanks to @JohnsonRan', - url: 'https://github.com/JohnsonRan', - }; case 'cors-proxy-zwei': return { text: 'Thanks to @Zwei', @@ -3455,10 +3448,10 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig | if (config?.SiteConfig) { setSiteSettings({ ...config.SiteConfig, - DoubanProxyType: config.SiteConfig.DoubanProxyType || 'melody-cdn-sharon', + DoubanProxyType: config.SiteConfig.DoubanProxyType || 'cmliussss-cdn-tencent', DoubanProxy: config.SiteConfig.DoubanProxy || '', DoubanImageProxyType: - config.SiteConfig.DoubanImageProxyType || 'melody-cdn-sharon', + config.SiteConfig.DoubanImageProxyType || 'cmliussss-cdn-tencent', DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '', DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false, FluidSearch: config.SiteConfig.FluidSearch || true, diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index 62605e5..93db66a 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -11,318 +11,6 @@ import { SearchResult } from '@/lib/types'; export const runtime = 'nodejs'; -// 认证相关接口定义 -export interface APIResponse { - success: boolean; - message: string; - data?: any; - timestamp: number; - signature: string; - server_fingerprint: string; -} - -const API_SECRET = 'moontv-is-the-best'; -// 验证服务器地址 -const AUTH_SERVER = 'https://moontv-auth.ihtw.moe'; - -// 全局变量存储公钥和指纹 -let serverPublicKey: crypto.KeyObject | null = null; -let expectedFingerprint = ''; - -// 验证相关的全局变量 -let networkFailureCount = 0; -const MAX_NETWORK_FAILURES = 3; -let currentMachineCode = ''; - -// 设备认证初始化状态 -let isDeviceAuthInitialized = false; - -/** - * 验证响应签名 - */ -async function verifyResponse(apiResp: APIResponse, requestTimestamp: string): Promise { - if (!serverPublicKey) { - throw new Error('服务器公钥未初始化'); - } - - // 验证服务器指纹 - if (apiResp.server_fingerprint !== expectedFingerprint) { - throw new Error('服务器指纹验证失败'); - } - - try { - const timestampToVerify = requestTimestamp; - const verified = await verifyTimestampSignature(timestampToVerify, apiResp.signature); - - if (!verified) { - throw new Error('时间戳签名验证失败'); - } - - } catch (error) { - throw new Error(`签名验证失败: ${error instanceof Error ? error.message : '未知错误'}`); - } -} - -async function verifyTimestampSignature(timestamp: string, signature: string): Promise { - try { - if (!serverPublicKey) { - console.error('❌ 服务器公钥未初始化'); - return false; - } - - // 将时间戳转换为字符串(与Go服务端保持一致) - const timestampString = String(timestamp); - - // 将十六进制签名转换为Buffer - const signatureBuffer = Buffer.from(signature, 'hex'); - - // 使用正确的方法:验证原始时间戳字符串 - // Go服务端实际上是对原始时间戳字符串进行签名的 - const verifier = crypto.createVerify('RSA-SHA256'); - verifier.update(timestampString, 'utf8'); - - const result = verifier.verify(serverPublicKey, signatureBuffer); - - return result; - } catch (error) { - console.error('❌ 时间戳签名验证出错:', error); - return false; - } -} - -export interface ServerInfo { - encrypted_public_key: string; - fingerprint: string; - encryption_method: string; - note: string; -} - -/** - * 从验证服务器获取公钥 - */ -async function fetchServerPublicKey(): Promise<{ publicKey: string, fingerprint: string }> { - try { - // 设置10秒超时 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const response = await fetch(`${AUTH_SERVER}/api/public_key`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'MoonTV/1.0.0' - }, - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const apiResp: APIResponse = await response.json(); - - if (!apiResp.success) { - throw new Error(`获取公钥失败: ${apiResp.message}`); - } - - const serverInfo = apiResp.data as ServerInfo; - const encryptedPublicKey = serverInfo.encrypted_public_key; - const serverFingerprint = serverInfo.fingerprint; - const decryptedPublicKeyPem = decryptWithAES(encryptedPublicKey, API_SECRET); - - return { - publicKey: decryptedPublicKeyPem, - fingerprint: serverFingerprint - }; - } catch (error) { - throw new Error(`获取公钥失败: ${error instanceof Error ? error.message : '未知错误'}`); - } -} - -/** - * 使用AES-GCM解密数据 - */ -function decryptWithAES(encryptedData: string, key: string): string { - try { - // 将密钥转换为32字节(SHA256哈希) - const keyHash = crypto.createHash('sha256').update(key).digest(); - - // Base64解码密文 - const encryptedBytes = Buffer.from(encryptedData, 'base64'); - - // 提取nonce(前12字节)和密文 - const nonceSize = 12; - const nonce = encryptedBytes.slice(0, nonceSize); - const ciphertext = encryptedBytes.slice(nonceSize, -16); // 除去最后16字节的认证标签 - const tag = encryptedBytes.slice(-16); // 最后16字节是认证标签 - - // 创建AES-GCM解密器 - const decipher = crypto.createDecipheriv('aes-256-gcm', keyHash, nonce); - decipher.setAuthTag(tag); - - const decrypted = decipher.update(ciphertext); - const final = decipher.final(); - - // 合并 Buffer 并转换为字符串 - const result = Buffer.concat([decrypted, final]); - return result.toString('utf8'); - } catch (error) { - throw new Error(`AES解密失败: ${error instanceof Error ? error.message : '未知错误'}`); - } -} - -/** - * 验证设备状态 - */ -async function verifyDevice(): Promise { - try { - console.log('🔄 开始设备验证...'); - - const config = await getConfig(); - - // 用户数量设置为0 - const userCount = config.UserConfig?.Users?.length || 0; - - // 生成请求时间戳 - const requestTimestamp = Date.now().toString(); - - // 设置10秒超时 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const response = await fetch(`${AUTH_SERVER}/api/verify_device`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'MoonTV/1.0.0' - }, - body: JSON.stringify({ - device_code: currentMachineCode, - auth_code: process.env.AUTH_TOKEN || '', - user_count: userCount, - timestamp: requestTimestamp - }), - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (response.status === 401) { - console.log('❌ 设备验证失败,401'); - process.exit(0); - } - - if (!response.ok) { - // 其他都认为是网络原因 - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const responseBody = await response.text(); - const apiResp: APIResponse = JSON.parse(responseBody); - - // 验证响应签名(使用我们发送的时间戳) - await verifyResponse(apiResp, requestTimestamp); - - if (!apiResp.success) { - console.error('❌ 设备验证失败'); - console.error(`验证失败原因: ${apiResp.message}`); - process.exit(0); - } - - // 重置网络失败计数 - networkFailureCount = 0; - console.log(`✅ 设备验证通过,用户数量: ${userCount}`); - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '未知错误'; - - // 网络问题 - networkFailureCount++; - console.warn(`⚠️ 网络验证失败 (${networkFailureCount}/${MAX_NETWORK_FAILURES}): ${errorMessage}`); - - if (networkFailureCount >= MAX_NETWORK_FAILURES) { - console.error('❌ 网络验证失败次数超过限制,重置认证信息'); - process.exit(0); - } - } -} - -/** - * 初始化设备认证信息 - */ -async function initializeDeviceAuth(): Promise { - // 如果已经初始化过,直接返回 - if (isDeviceAuthInitialized) { - console.log('🔑 设备认证信息已初始化,跳过重复初始化'); - return; - } - - try { - // 获取环境变量 - const authToken = process.env.AUTH_TOKEN; - const username = process.env.USERNAME; - const password = process.env.PASSWORD; - - if (!authToken || !username || !password) { - console.log('⚠️ 缺少认证环境变量,跳过设备验证'); - return; - } - - // 生成机器码(包含存储URL信息) - const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; - let storageUrl = ''; - - // 根据存储类型获取对应的URL - switch (storageType) { - case 'kvrocks': - storageUrl = process.env.KVROCKS_URL || ''; - break; - case 'upstash': - storageUrl = process.env.UPSTASH_URL || ''; - break; - case 'redis': - storageUrl = process.env.REDIS_URL || ''; - break; - default: - storageUrl = 'localstorage'; - } - - const combinedString = authToken + username + password + storageUrl; - const encoder = new TextEncoder(); - const data = encoder.encode(combinedString); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - const machineCode = hashHex.substring(0, 16); - currentMachineCode = machineCode; - - // 从验证服务器获取公钥 - const { publicKey, fingerprint } = await fetchServerPublicKey(); - - // 设置全局变量供签名验证使用 - try { - serverPublicKey = crypto.createPublicKey({ - key: publicKey, - format: 'pem', - type: 'spki' - }); - } catch (keyError) { - console.error('❌ 公钥KeyObject创建失败:', keyError); - process.exit(0); - } - expectedFingerprint = fingerprint; - - // 标记为已初始化 - isDeviceAuthInitialized = true; - console.log('🔑 设备认证信息初始化成功'); - } catch (error) { - console.error('❌ 设备认证信息初始化失败:', error); - process.exit(0); - } -} - export async function GET(request: NextRequest) { console.log(request.url); try { @@ -351,13 +39,6 @@ export async function GET(request: NextRequest) { } async function cronJob() { - // 初始化设备认证信息 - await initializeDeviceAuth(); - - // 执行设备验证 - await verifyDevice(); - - // 执行其他定时任务 await refreshConfig(); await refreshAllLiveChannels(); await refreshRecordAndFavorites(); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2a6582d..e24fcea 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -46,10 +46,10 @@ export default async function RootLayout({ process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。'; - let doubanProxyType = process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'melody-cdn-sharon'; + let doubanProxyType = process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent'; let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || ''; let doubanImageProxyType = - process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'melody-cdn-sharon'; + process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent'; let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || ''; let disableYellowFilter = process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true'; diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 0fb4bba..2d55278 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -67,8 +67,8 @@ export const UserMenu: React.FC = () => { const [enableOptimization, setEnableOptimization] = useState(true); const [fluidSearch, setFluidSearch] = useState(true); const [liveDirectConnect, setLiveDirectConnect] = useState(false); - const [doubanDataSource, setDoubanDataSource] = useState('melody-cdn-sharon'); - const [doubanImageProxyType, setDoubanImageProxyType] = useState('melody-cdn-sharon'); + const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent'); + const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent'); const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState(''); const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false); const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] = @@ -77,7 +77,6 @@ export const UserMenu: React.FC = () => { // 豆瓣数据源选项 const doubanDataSourceOptions = [ { value: 'direct', label: '直连(服务器直接请求豆瓣)' }, - { value: 'melody-cdn-sharon', label: '豆瓣 CDN By 旋律(Sharon CDN)' }, { value: 'cors-proxy-zwei', label: 'Cors Proxy By Zwei' }, { value: 'cmliussss-cdn-tencent', @@ -92,7 +91,6 @@ export const UserMenu: React.FC = () => { { value: 'direct', label: '直连(浏览器直接请求豆瓣)' }, { value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' }, { value: 'img3', label: '豆瓣官方精品 CDN(阿里云)' }, - { value: 'melody-cdn-sharon', label: '豆瓣 CDN By 旋律(Sharon CDN)' }, { value: 'cmliussss-cdn-tencent', label: '豆瓣 CDN By CMLiussss(腾讯云)', @@ -140,7 +138,7 @@ export const UserMenu: React.FC = () => { const savedDoubanDataSource = localStorage.getItem('doubanDataSource'); const defaultDoubanProxyType = - (window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || 'melody-cdn-sharon'; + (window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent'; if (savedDoubanDataSource !== null) { setDoubanDataSource(savedDoubanDataSource); } else if (defaultDoubanProxyType) { @@ -160,7 +158,7 @@ export const UserMenu: React.FC = () => { 'doubanImageProxyType' ); const defaultDoubanImageProxyType = - (window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'melody-cdn-sharon'; + (window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent'; if (savedDoubanImageProxyType !== null) { setDoubanImageProxyType(savedDoubanImageProxyType); } else if (defaultDoubanImageProxyType) { @@ -403,11 +401,6 @@ export const UserMenu: React.FC = () => { // 获取感谢信息 const getThanksInfo = (dataSource: string) => { switch (dataSource) { - case 'melody-cdn-sharon': - return { - text: 'Thanks to @JohnsonRan', - url: 'https://github.com/JohnsonRan', - }; case 'cors-proxy-zwei': return { text: 'Thanks to @Zwei', @@ -426,11 +419,11 @@ export const UserMenu: React.FC = () => { const handleResetSettings = () => { const defaultDoubanProxyType = - (window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || 'melody-cdn-sharon'; + (window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent'; const defaultDoubanProxy = (window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || ''; const defaultDoubanImageProxyType = - (window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'melody-cdn-sharon'; + (window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent'; const defaultDoubanImageProxyUrl = (window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || ''; const defaultFluidSearch = diff --git a/src/instrumentation.ts b/src/instrumentation.ts deleted file mode 100644 index 0090ad6..0000000 --- a/src/instrumentation.ts +++ /dev/null @@ -1,576 +0,0 @@ -/* eslint-disable */ -/** - * Next.js Instrumentation Hook - * 在应用启动时执行关键检查,失败时立即退出 - */ - -import * as crypto from 'crypto'; - -// 认证相关接口定义 -export interface APIResponse { - success: boolean; - message: string; - data?: any; - timestamp: number; - signature: string; - server_fingerprint: string; -} - -export interface ServerInfo { - encrypted_public_key: string; - fingerprint: string; - encryption_method: string; - note: string; -} - -// API密钥 - 用于解密公钥 -const API_SECRET = 'moontv-is-the-best'; - -// 验证服务器地址 -const AUTH_SERVER = 'https://moontv-auth.ihtw.moe'; - -// 全局变量存储公钥和指纹 -let serverPublicKey: crypto.KeyObject | null = null; -let expectedFingerprint = ''; - -// 验证相关的全局变量 -let currentMachineCode = ''; - -/** - * 使用AES-GCM解密数据 - */ -function decryptWithAES(encryptedData: string, key: string): string { - try { - // 将密钥转换为32字节(SHA256哈希) - const keyHash = crypto.createHash('sha256').update(key).digest(); - - // Base64解码密文 - const encryptedBytes = Buffer.from(encryptedData, 'base64'); - - // 提取nonce(前12字节)和密文 - const nonceSize = 12; - const nonce = encryptedBytes.slice(0, nonceSize); - const ciphertext = encryptedBytes.slice(nonceSize, -16); // 除去最后16字节的认证标签 - const tag = encryptedBytes.slice(-16); // 最后16字节是认证标签 - - // 创建AES-GCM解密器 - const decipher = crypto.createDecipheriv('aes-256-gcm', keyHash, nonce); - decipher.setAuthTag(tag); - - const decrypted = decipher.update(ciphertext); - const final = decipher.final(); - - // 合并 Buffer 并转换为字符串 - const result = Buffer.concat([decrypted, final]); - return result.toString('utf8'); - } catch (error) { - throw new Error(`AES解密失败: ${error instanceof Error ? error.message : '未知错误'}`); - } -} - -/** - * 从验证服务器获取公钥 - */ -async function fetchServerPublicKey(): Promise<{ publicKey: string, fingerprint: string }> { - try { - // 设置10秒超时 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const response = await fetch(`${AUTH_SERVER}/api/public_key`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'MoonTV/1.0.0' - }, - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const apiResp: APIResponse = await response.json(); - - if (!apiResp.success) { - throw new Error(`API错误: ${apiResp.message}`); - } - - const serverInfo = apiResp.data as ServerInfo; - const encryptedPublicKey = serverInfo.encrypted_public_key; - const serverFingerprint = serverInfo.fingerprint; - const decryptedPublicKeyPem = decryptWithAES(encryptedPublicKey, API_SECRET); - - console.log('✅ 公钥解密成功'); - - return { publicKey: decryptedPublicKeyPem, fingerprint: serverFingerprint }; - - } catch (error) { - throw new Error(`获取服务器公钥失败: ${error instanceof Error ? error.message : '未知错误'}`); - } -} - -/** - * 验证API响应的签名 - */ -async function verifyResponse(apiResp: APIResponse, requestTimestamp: string): Promise { - if (!serverPublicKey) { - throw new Error('未获取服务器公钥'); - } - - // 验证服务器指纹 - if (expectedFingerprint && apiResp.server_fingerprint !== expectedFingerprint) { - throw new Error('服务器指纹不匹配,可能是伪造的服务器'); - } - - try { - // 现在服务端只对时间戳字符串进行签名,而不是整个响应对象 - // 使用我们发送请求时的时间戳,而不是响应中的时间戳 - const timestampToVerify = requestTimestamp; - const verified = await verifyTimestampSignature(timestampToVerify, apiResp.signature); - - if (!verified) { - throw new Error('时间戳签名验证失败'); - } - - } catch (error) { - throw new Error(`签名验证失败: ${error instanceof Error ? error.message : '未知错误'}`); - } -} - -/** - * 验证时间戳的RSA签名(服务端现在只对时间戳字符串进行签名) - */ -async function verifyTimestampSignature(timestamp: string, signature: string): Promise { - try { - if (!serverPublicKey) { - console.error('❌ 服务器公钥未初始化'); - return false; - } - - // 将时间戳转换为字符串(与Go服务端保持一致) - const timestampString = String(timestamp); - - // 将十六进制签名转换为Buffer - const signatureBuffer = Buffer.from(signature, 'hex'); - - // 使用正确的方法:验证原始时间戳字符串 - // Go服务端实际上是对原始时间戳字符串进行签名的 - const verifier = crypto.createVerify('RSA-SHA256'); - verifier.update(timestampString, 'utf8'); - - const result = verifier.verify(serverPublicKey, signatureBuffer); - - return result; - } catch (error) { - console.error('❌ 时间戳签名验证出错:', error); - return false; - } -} - -/** - * 模拟Go的json.Marshal行为进行JSON序列化 - * Go对map[string]interface{}会按键的字母顺序排序 - */ -function serializeAsGoJsonMarshal(obj: any): string { - if (obj === null) return 'null'; - if (obj === undefined) return 'undefined'; - - if (typeof obj === 'string') { - return JSON.stringify(obj); - } - - if (typeof obj === 'number' || typeof obj === 'boolean') { - return String(obj); - } - - // 处理BigInt类型 - if (typeof obj === 'bigint') { - return String(obj); - } - - if (Array.isArray(obj)) { - const items = obj.map(item => serializeAsGoJsonMarshal(item)); - return '[' + items.join(',') + ']'; - } - - if (typeof obj === 'object') { - // 按键的字母顺序排序(Go的map[string]interface{}行为) - const sortedKeys = Object.keys(obj).sort(); - const pairs: string[] = []; - - for (const key of sortedKeys) { - if (obj[key] !== undefined) { - const serializedKey = JSON.stringify(key); - const serializedValue = serializeAsGoJsonMarshal(obj[key]); - pairs.push(`${serializedKey}:${serializedValue}`); - } - } - - return '{' + pairs.join(',') + '}'; - } - - // 处理其他类型,包括可能的BigInt - try { - return JSON.stringify(obj); - } catch (error) { - // 如果JSON.stringify失败(比如因为BigInt),尝试转换为字符串 - if (error instanceof TypeError && error.message.includes('BigInt')) { - return String(obj); - } - throw error; - } -} - -/** - * 注册设备到认证服务器 - */ -async function registerDevice(authCode: string, deviceCode: string) { - try { - // 生成请求时间戳 - const requestTimestamp = Date.now().toString(); - - // 设置10秒超时 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const response = await fetch(`${AUTH_SERVER}/api/register_device`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'MoonTV/1.0.0' - }, - body: JSON.stringify({ - auth_code: authCode, - device_code: deviceCode, - timestamp: requestTimestamp - }), - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const responseBody = await response.text(); - const apiResp: APIResponse = JSON.parse(responseBody); - - // 验证响应签名(使用我们发送的时间戳) - await verifyResponse(apiResp, requestTimestamp); - - if (!apiResp.success) { - throw new Error(`设备注册失败: ${apiResp.message}`); - } - - console.log(`✅ 设备注册成功`); - } catch (error) { - throw new Error(`设备注册失败: ${error instanceof Error ? error.message : '未知错误'}`); - } -} - - - - - -/** - * 环境变量检查 - */ -function checkEnvironment(): void { - // 检查 USERNAME - const username = process.env.USERNAME; - if (!username || username.trim() === '') { - console.error('❌ USERNAME 环境变量不得为空'); - console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(0); - } - - // 检查 PASSWORD - const password = process.env.PASSWORD; - if (!password || password.trim() === '') { - console.error('❌ PASSWORD 环境变量不得为空'); - console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(0); - } - - // 检查弱密码 - const weakPasswords = [ - 'admin_password', - 'password', - '123456', - 'admin', - 'root', - 'password123', - '12345678', - 'qwerty', - 'abc123', - 'admin123', - 'test123', - 'password1', - '000000', - '111111', - '11111111112233', - '112233', - '123123', - '123321', - '654321', - '666666', - '888888', - 'abcdef', - 'abcabc', - 'a1b2c3', - 'aaa111', - '123qwe', - 'qweasd' - ]; - - if (weakPasswords.includes(password.toLowerCase())) { - console.error(`❌ PASSWORD 不能使用常见弱密码: ${password}`); - console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(0); - } - - if (password.length < 8) { - console.error('❌ PASSWORD 长度不能少于8位'); - console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(0); - } - - // 检查密码不能与用户名相同 - if (password.toLowerCase() === username.toLowerCase()) { - console.error('❌ PASSWORD 不能与 USERNAME 相同'); - console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(0); - } - - // 检查 AUTH_TOKEN - const authToken = process.env.AUTH_TOKEN; - if (!authToken || authToken.trim() === '') { - console.error('❌ AUTH_TOKEN 不得为空'); - console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(0); - } - - // 检查 AUTH_SERVER(可选,但如果设置了需要验证格式) - const authServer = process.env.AUTH_SERVER; - if (authServer && authServer.trim() !== '') { - if (!authServer.startsWith('https://') && !authServer.startsWith('http://')) { - console.error('❌ AUTH_SERVER 必须以 http:// 或 https:// 开头'); - console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(0); - } - } -} - -/** - * 认证检查 - */ -async function checkAuthentication(): Promise { - // 获取环境变量 - const authToken = process.env.AUTH_TOKEN; - const username = process.env.USERNAME; - const password = process.env.PASSWORD; - - if (!authToken || !username || !password) { - console.error('❌ 认证检查失败:缺少必需的环境变量'); - console.error('🚨 认证检查失败,服务器即将退出'); - process.exit(0); - } - - try { - // 第一步:生成机器码(包含存储URL信息) - const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; - let storageUrl = ''; - - // 根据存储类型获取对应的URL - switch (storageType) { - case 'kvrocks': - storageUrl = process.env.KVROCKS_URL || ''; - break; - case 'upstash': - storageUrl = process.env.UPSTASH_URL || ''; - break; - case 'redis': - storageUrl = process.env.REDIS_URL || ''; - break; - default: - storageUrl = 'localstorage'; - } - - const combinedString = authToken + username + password + storageUrl; - const encoder = new TextEncoder(); - const data = encoder.encode(combinedString); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - const machineCode = hashHex.substring(0, 16); - currentMachineCode = machineCode; // 保存到全局变量 - - // 第二步:从验证服务器获取公钥 - const { publicKey, fingerprint } = await fetchServerPublicKey(); - - // 设置全局变量供签名验证使用 - // 将PEM格式的公钥字符串转换为KeyObject - try { - serverPublicKey = crypto.createPublicKey({ - key: publicKey, - format: 'pem', - type: 'spki' - }); - } catch (keyError) { - console.error('❌ 公钥KeyObject创建失败:', keyError); - throw new Error(`公钥格式错误: ${keyError instanceof Error ? keyError.message : '未知错误'}`); - } - expectedFingerprint = fingerprint; - - console.log('🔑 公钥获取成功,准备进行设备注册'); - - // 第三步:注册设备 - // 使用机器码作为认证码和设备码 - const deviceCode = machineCode; - await registerDevice(authToken, deviceCode); - - console.log('🎉 设备认证流程完成'); - } catch (error) { - console.error('❌ 认证流程失败:', error instanceof Error ? error.message : '未知错误'); - console.error('🚨 认证检查失败,服务器即将退出'); - process.exit(0); - } -} - -/** - * 数据库配置检查 - */ -function checkDatabaseConfig(): void { - const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; - - // 检查存储类型配置 - const allowedStorageTypes = ['localstorage', 'kvrocks', 'upstash', 'redis']; - if (!allowedStorageTypes.includes(storageType)) { - console.error(`❌ NEXT_PUBLIC_STORAGE_TYPE 必须是 ${allowedStorageTypes.join(', ')} 之一,当前值: ${storageType}`); - console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(0); - } - - // 根据存储类型检查相应的环境变量 - switch (storageType) { - case 'kvrocks': - const kvrocksUrl = process.env.KVROCKS_URL; - if (!kvrocksUrl || kvrocksUrl.trim() === '') { - console.error('❌ KVROCKS_URL 环境变量不得为空'); - console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(0); - } - if (!kvrocksUrl.startsWith('redis://')) { - console.error('❌ KVROCKS_URL 必须以 redis:// 开头'); - console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(0); - } - break; - - case 'upstash': - const upstashUrl = process.env.UPSTASH_URL; - const upstashToken = process.env.UPSTASH_TOKEN; - - if (!upstashUrl || upstashUrl.trim() === '') { - console.error('❌ UPSTASH_URL 环境变量不得为空'); - console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(0); - } - if (!upstashUrl.startsWith('https://')) { - console.error('❌ UPSTASH_URL 必须以 https:// 开头'); - console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(0); - } - - if (!upstashToken || upstashToken.trim() === '') { - console.error('❌ UPSTASH_TOKEN 环境变量不得为空'); - console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(0); - } - break; - - case 'redis': - const redisUrl = process.env.REDIS_URL; - if (!redisUrl || redisUrl.trim() === '') { - console.error('❌ REDIS_URL 环境变量不得为空'); - console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(0); - } - if (!redisUrl.startsWith('redis://') && !redisUrl.startsWith('rediss://')) { - console.error('❌ REDIS_URL 必须以 redis:// 或 rediss:// 开头'); - console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(0); - } - break; - } -} - -/** - * 执行启动检查并在失败时退出 - */ -async function runCriticalStartupChecks(): Promise { - console.log('🔧 执行关键启动检查...'); - - // 1. 环境变量检查 - console.log('📝 检查环境变量...'); - checkEnvironment(); - console.log('✅ 环境变量检查通过'); - - // 2. 数据库配置检查 - console.log('🗄️ 检查数据库配置...'); - checkDatabaseConfig(); - console.log('✅ 数据库配置检查通过'); - - // 3. 认证检查 - console.log('🔐 检查认证信息...'); - await checkAuthentication(); - console.log('✅ 认证检查通过'); - - console.log('🎉 所有关键检查通过,服务器正常启动'); -} - -/** - * Next.js Instrumentation Hook - * 这个函数会在应用启动时自动被 Next.js 调用 - */ -export async function register() { - // 只在服务器端运行 - if (typeof window === 'undefined' && typeof process !== 'undefined' && process.on && typeof process.on === 'function') { - console.log('🚀 MoonTV 启动检查开始...'); - - // 注册进程退出事件处理 - process.on('SIGINT', () => { - console.log('\n🛑 收到 SIGINT 信号,正在优雅关闭...'); - process.exit(0); - }); - - process.on('SIGTERM', () => { - console.log('\n🛑 收到 SIGTERM 信号,正在优雅关闭...'); - process.exit(0); - }); - - try { - await runCriticalStartupChecks(); - } catch (error) { - console.error('💥 启动检查过程中发生未预期错误:', error); - console.error('🚨 服务器即将退出'); - process.exit(0); - } - } -} - -// 导出检查函数供其他模块使用(如果需要) -export { - checkAuthentication, - checkDatabaseConfig, - checkEnvironment, - decryptWithAES, - fetchServerPublicKey, - verifyResponse, - verifyTimestampSignature, - serializeAsGoJsonMarshal -}; \ No newline at end of file diff --git a/src/lib/config.ts b/src/lib/config.ts index 09441c3..d984c63 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -209,10 +209,10 @@ async function getInitConfig(configFile: string, subConfig: { Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5, SiteInterfaceCacheTime: cfgFile.cache_time || 7200, DoubanProxyType: - process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'melody-cdn-sharon', + process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent', DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '', DoubanImageProxyType: - process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'melody-cdn-sharon', + process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent', DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '', DisableYellowFilter: process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true', diff --git a/src/lib/douban.client.ts b/src/lib/douban.client.ts index 3bc1561..e4608c9 100644 --- a/src/lib/douban.client.ts +++ b/src/lib/douban.client.ts @@ -95,7 +95,6 @@ async function fetchWithTimeout( function getDoubanProxyConfig(): { proxyType: | 'direct' - | 'melody-cdn-sharon' | 'cors-proxy-zwei' | 'cmliussss-cdn-tencent' | 'cmliussss-cdn-ali' @@ -106,7 +105,7 @@ function getDoubanProxyConfig(): { const doubanProxyType = localStorage.getItem('doubanDataSource') || (window as any).RUNTIME_CONFIG?.DOUBAN_PROXY_TYPE || - 'melody-cdn-sharon'; + 'cmliussss-cdn-tencent'; const doubanProxy = localStorage.getItem('doubanProxyUrl') || (window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || @@ -199,8 +198,6 @@ export async function getDoubanCategories( const { kind, category, type, pageLimit = 20, pageStart = 0 } = params; const { proxyType, proxyUrl } = getDoubanProxyConfig(); switch (proxyType) { - case 'melody-cdn-sharon': - return fetchDoubanCategories(params, 'https://douban.ihtw.moe/'); case 'cors-proxy-zwei': return fetchDoubanCategories(params, 'https://ciao-cors.is-an.org/'); case 'cmliussss-cdn-tencent': @@ -234,8 +231,6 @@ export async function getDoubanList( const { tag, type, pageLimit = 20, pageStart = 0 } = params; const { proxyType, proxyUrl } = getDoubanProxyConfig(); switch (proxyType) { - case 'melody-cdn-sharon': - return fetchDoubanList(params, 'https://douban.ihtw.moe/'); case 'cors-proxy-zwei': return fetchDoubanList(params, 'https://ciao-cors.is-an.org/'); case 'cmliussss-cdn-tencent': @@ -356,8 +351,6 @@ export async function getDoubanRecommends( } = params; const { proxyType, proxyUrl } = getDoubanProxyConfig(); switch (proxyType) { - case 'melody-cdn-sharon': - return fetchDoubanRecommends(params, 'https://douban.ihtw.moe/'); case 'cors-proxy-zwei': return fetchDoubanRecommends(params, 'https://ciao-cors.is-an.org/'); case 'cmliussss-cdn-tencent': diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d376f8a..3c287b5 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -7,7 +7,6 @@ function getDoubanImageProxyConfig(): { | 'direct' | 'server' | 'img3' - | 'melody-cdn-sharon' | 'cmliussss-cdn-tencent' | 'cmliussss-cdn-ali' | 'custom'; @@ -16,7 +15,7 @@ function getDoubanImageProxyConfig(): { const doubanImageProxyType = localStorage.getItem('doubanImageProxyType') || (window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || - 'melody-cdn-sharon'; + 'cmliussss-cdn-tencent'; const doubanImageProxy = localStorage.getItem('doubanImageProxyUrl') || (window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || @@ -42,8 +41,6 @@ export function processImageUrl(originalUrl: string): string { switch (proxyType) { case 'server': return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`; - case 'melody-cdn-sharon': - return `https://douban.ihtw.moe/${encodeURIComponent(originalUrl)}`; case 'img3': return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com'); case 'cmliussss-cdn-tencent':