feat: ready for public

This commit is contained in:
shinya
2025-08-26 22:53:04 +08:00
parent 56edd35675
commit 2a338f1bab
11 changed files with 134 additions and 1461 deletions

View File

@@ -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,

View File

@@ -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();

View File

@@ -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';

View File

@@ -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 =

View File

@@ -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
};

View File

@@ -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',

View File

@@ -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':

View File

@@ -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':