feat: ready for public
This commit is contained in:
330
README.md
330
README.md
@@ -1,10 +1,10 @@
|
||||
# MoonTV
|
||||
|
||||
<div align="center">
|
||||
<img src="public/logo.png" alt="LibreTV Logo" width="120">
|
||||
<img src="public/logo.png" alt="MoonTV Logo" width="120">
|
||||
</div>
|
||||
|
||||
> 🎬 **MoonTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、本地/云端存储,让你可以随时随地畅享海量免费影视内容。
|
||||
> 🎬 **MoonTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -20,14 +20,15 @@
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果。
|
||||
- 🔍 **多源聚合搜索**:一次搜索立刻返回全源结果。
|
||||
- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。
|
||||
- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。
|
||||
- ❤️ **收藏 + 继续观看**:支持 Redis/D1/Upstash 存储,多端同步进度。
|
||||
- ❤️ **收藏 + 继续观看**:支持 Kvrocks/Redis/Upstash 存储,多端同步进度。
|
||||
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
|
||||
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
|
||||
- 🚀 **极简部署**:一条 Docker 命令即可将完整服务跑起来,或免费部署到 Vercel、Netlify 和 ~~Cloudflare~~。
|
||||
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)
|
||||
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)。
|
||||
|
||||
### 注意:部署后项目为空壳项目,无内置播放源和直播源,需要自行收集
|
||||
|
||||
<details>
|
||||
<summary>点击查看项目截图</summary>
|
||||
@@ -36,14 +37,15 @@
|
||||
<img src="public/screenshot3.png" alt="项目截图" style="max-width:600px">
|
||||
</details>
|
||||
|
||||
### 请不要在 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 趋势
|
||||
|
||||
[](https://starchart.cc/LunaTechLab/MoonTV)
|
||||
[](https://www.star-history.com/#MoonTechLab/LunaTV&Date)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 如果已经初始化过,直接返回
|
||||
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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
// 获取环境变量
|
||||
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<void> {
|
||||
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
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user