diff --git a/package.json b/package.json index aee6ba1..d19a4cc 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,13 @@ "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^2.2.4", "@heroicons/react": "^2.2.0", + "@types/crypto-js": "^4.2.2", "@upstash/redis": "^1.25.0", "@vidstack/react": "^1.12.13", "artplayer": "^5.2.5", "bs58": "^6.0.0", "clsx": "^2.0.0", + "crypto-js": "^4.2.0", "framer-motion": "^12.18.1", "he": "^1.2.0", "hls.js": "^1.6.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cc67e1..cd4c437 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@18.3.1) + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 '@upstash/redis': specifier: ^1.25.0 version: 1.35.1 @@ -41,6 +44,9 @@ importers: clsx: specifier: ^2.0.0 version: 2.1.1 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 framer-motion: specifier: ^12.18.1 version: 12.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1466,6 +1472,9 @@ packages: resolution: {integrity: sha512-cAw/jKBzo98m6Xz1X5ETqymWfIMbXbu6nK15W4LQYjeHJkVqSmM5PO8Bd9KVHQJ/F4rHcSso9LcjtgCW6TGu2w==} deprecated: This is a stub types definition. bs58 provides its own type definitions, so you do not need this installed. + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -2301,6 +2310,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + crypto-random-string@2.0.0: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} @@ -6767,6 +6779,8 @@ snapshots: dependencies: bs58: 6.0.0 + '@types/crypto-js@4.2.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -7664,6 +7678,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + crypto-random-string@2.0.0: {} css-select@5.1.0: diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b2aad91..154f2ba 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -25,6 +25,7 @@ import { Check, ChevronDown, ChevronUp, + Database, ExternalLink, FileText, FolderOpen, @@ -39,6 +40,7 @@ import Swal from 'sweetalert2'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; +import DataMigration from '@/components/DataMigration'; import PageLayout from '@/components/PageLayout'; // 统一弹窗方法(必须在首次使用前定义) @@ -2042,6 +2044,7 @@ function AdminPageClient() { siteConfig: false, categoryConfig: false, configFile: false, + dataMigration: false, }); // 获取管理员配置 @@ -2229,6 +2232,23 @@ function AdminPageClient() { > + + {/* 数据迁移标签 - 仅站长可见 */} + {role === 'owner' && ( + + } + isExpanded={expandedTabs.dataMigration} + onToggle={() => toggleTab('dataMigration')} + > + + + )} diff --git a/src/app/api/admin/config_file/route.ts b/src/app/api/admin/config_file/route.ts index 6f1fded..a5989b6 100644 --- a/src/app/api/admin/config_file/route.ts +++ b/src/app/api/admin/config_file/route.ts @@ -32,7 +32,7 @@ export async function POST(request: NextRequest) { if (username !== process.env.USERNAME) { return NextResponse.json( { error: '权限不足,只有站长可以修改配置文件' }, - { status: 403 } + { status: 401 } ); } diff --git a/src/app/api/admin/config_subscription/fetch/route.ts b/src/app/api/admin/config_subscription/fetch/route.ts index cab2736..ebe5c20 100644 --- a/src/app/api/admin/config_subscription/fetch/route.ts +++ b/src/app/api/admin/config_subscription/fetch/route.ts @@ -15,7 +15,7 @@ export async function POST(request: NextRequest) { if (authInfo.username !== process.env.USERNAME) { return NextResponse.json( { error: '权限不足,只有站长可以拉取配置订阅' }, - { status: 403 } + { status: 401 } ); } diff --git a/src/app/api/admin/data_migration/export/route.ts b/src/app/api/admin/data_migration/export/route.ts new file mode 100644 index 0000000..400afed --- /dev/null +++ b/src/app/api/admin/data_migration/export/route.ts @@ -0,0 +1,131 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ + +import { NextRequest, NextResponse } from 'next/server'; +import { promisify } from 'util'; +import { gzip } from 'zlib'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { SimpleCrypto } from '@/lib/crypto'; +import { db } from '@/lib/db'; +import { CURRENT_VERSION } from '@/lib/version'; + +const gzipAsync = promisify(gzip); + +export async function POST(req: NextRequest) { + try { + // 检查存储类型 + const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + if (storageType === 'localstorage') { + return NextResponse.json( + { error: '不支持本地存储进行数据迁移' }, + { status: 400 } + ); + } + + // 验证身份和权限 + const authInfo = getAuthInfoFromCookie(req); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + // 检查用户权限(只有站长可以导出数据) + if (authInfo.username !== process.env.USERNAME) { + return NextResponse.json({ error: '权限不足,只有站长可以导出数据' }, { status: 401 }); + } + + const config = await db.getAdminConfig(); + if (!config) { + return NextResponse.json({ error: '无法获取配置' }, { status: 500 }); + } + + // 解析请求体获取密码 + const { password } = await req.json(); + if (!password || typeof password !== 'string') { + return NextResponse.json({ error: '请提供加密密码' }, { status: 400 }); + } + + // 收集所有数据 + const exportData = { + timestamp: new Date().toISOString(), + serverVersion: CURRENT_VERSION, + data: { + // 管理员配置 + adminConfig: config, + // 所有用户数据 + userData: {} as { [username: string]: any } + } + }; + + // 获取所有用户 + let allUsers = await db.getAllUsers(); + // 添加站长用户 + allUsers.push(process.env.USERNAME); + allUsers = Array.from(new Set(allUsers)); + + // 为每个用户收集数据 + for (const username of allUsers) { + const userData = { + // 播放记录 + playRecords: await db.getAllPlayRecords(username), + // 收藏夹 + favorites: await db.getAllFavorites(username), + // 搜索历史 + searchHistory: await db.getSearchHistory(username), + // 跳过片头片尾配置 + skipConfigs: await db.getAllSkipConfigs(username), + // 用户密码(通过验证空密码来检查用户是否存在,然后获取密码) + password: await getUserPassword(username) + }; + + exportData.data.userData[username] = userData; + } + + // 将数据转换为JSON字符串 + const jsonData = JSON.stringify(exportData); + + // 先压缩数据 + const compressedData = await gzipAsync(jsonData); + + // 使用提供的密码加密压缩后的数据 + const encryptedData = SimpleCrypto.encrypt(compressedData.toString('base64'), password); + + // 生成文件名 + const now = new Date(); + const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`; + const filename = `moontv-backup-${timestamp}.dat`; + + // 返回加密的数据作为文件下载 + return new NextResponse(encryptedData, { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': encryptedData.length.toString(), + }, + }); + + } catch (error) { + console.error('数据导出失败:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : '导出失败' }, + { status: 500 } + ); + } +} + +// 辅助函数:获取用户密码(通过数据库直接访问) +async function getUserPassword(username: string): Promise { + try { + // 使用 Redis 存储的直接访问方法 + const storage = (db as any).storage; + if (storage && typeof storage.client?.get === 'function') { + const passwordKey = `u:${username}:pwd`; + const password = await storage.client.get(passwordKey); + return password; + } + return null; + } catch (error) { + console.error(`获取用户 ${username} 密码失败:`, error); + return null; + } +} diff --git a/src/app/api/admin/data_migration/import/route.ts b/src/app/api/admin/data_migration/import/route.ts new file mode 100644 index 0000000..6c6d279 --- /dev/null +++ b/src/app/api/admin/data_migration/import/route.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ + +import { NextRequest, NextResponse } from 'next/server'; +import { promisify } from 'util'; +import { gunzip } from 'zlib'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { SimpleCrypto } from '@/lib/crypto'; +import { db } from '@/lib/db'; + +const gunzipAsync = promisify(gunzip); + +export async function POST(req: NextRequest) { + try { + // 检查存储类型 + const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + if (storageType === 'localstorage') { + return NextResponse.json( + { error: '不支持本地存储进行数据迁移' }, + { status: 400 } + ); + } + + // 验证身份和权限 + const authInfo = getAuthInfoFromCookie(req); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + // 检查用户权限(只有站长可以导入数据) + if (authInfo.username !== process.env.USERNAME) { + return NextResponse.json({ error: '权限不足,只有站长可以导入数据' }, { status: 401 }); + } + + // 解析表单数据 + const formData = await req.formData(); + const file = formData.get('file') as File; + const password = formData.get('password') as string; + + if (!file) { + return NextResponse.json({ error: '请选择备份文件' }, { status: 400 }); + } + + if (!password) { + return NextResponse.json({ error: '请提供解密密码' }, { status: 400 }); + } + + // 读取文件内容 + const encryptedData = await file.text(); + + // 解密数据 + let decryptedData: string; + try { + decryptedData = SimpleCrypto.decrypt(encryptedData, password); + } catch (error) { + return NextResponse.json({ error: '解密失败,请检查密码是否正确' }, { status: 400 }); + } + + // 解压缩数据 + const compressedBuffer = Buffer.from(decryptedData, 'base64'); + const decompressedBuffer = await gunzipAsync(compressedBuffer); + const decompressedData = decompressedBuffer.toString(); + + // 解析JSON数据 + let importData: any; + try { + importData = JSON.parse(decompressedData); + } catch (error) { + return NextResponse.json({ error: '备份文件格式错误' }, { status: 400 }); + } + + // 验证数据格式 + if (!importData.data || !importData.data.adminConfig || !importData.data.userData) { + return NextResponse.json({ error: '备份文件格式无效' }, { status: 400 }); + } + + // 开始导入数据 - 先清空现有数据 + await db.clearAllData(); + + // 导入管理员配置 + await db.saveAdminConfig(importData.data.adminConfig); + + // 导入用户数据 + const userData = importData.data.userData; + for (const username in userData) { + const user = userData[username]; + + // 重新注册用户(包含密码) + if (user.password) { + await db.registerUser(username, user.password); + } + + // 导入播放记录 + if (user.playRecords) { + for (const [key, record] of Object.entries(user.playRecords)) { + await (db as any).storage.setPlayRecord(username, key, record); + } + } + + // 导入收藏夹 + if (user.favorites) { + for (const [key, favorite] of Object.entries(user.favorites)) { + await (db as any).storage.setFavorite(username, key, favorite); + } + } + + // 导入搜索历史 + if (user.searchHistory && Array.isArray(user.searchHistory)) { + for (const keyword of user.searchHistory.reverse()) { // 反转以保持顺序 + await db.addSearchHistory(username, keyword); + } + } + + // 导入跳过片头片尾配置 + if (user.skipConfigs) { + for (const [key, skipConfig] of Object.entries(user.skipConfigs)) { + const [source, id] = key.split('+'); + if (source && id) { + await db.setSkipConfig(username, source, id, skipConfig as any); + } + } + } + } + + return NextResponse.json({ + message: '数据导入成功', + importedUsers: Object.keys(userData).length, + timestamp: importData.timestamp, + serverVersion: typeof importData.serverVersion === 'string' ? importData.serverVersion : '未知版本' + }); + + } catch (error) { + console.error('数据导入失败:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : '导入失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 8594c4e..85ddf5d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -6,7 +6,8 @@ import { AlertCircle, CheckCircle } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useState } from 'react'; -import { checkForUpdates, CURRENT_VERSION, UpdateStatus } from '@/lib/version'; +import { CURRENT_VERSION } from '@/lib/version'; +import { checkForUpdates, UpdateStatus } from '@/lib/version_check'; import { useSite } from '@/components/SiteProvider'; import { ThemeToggle } from '@/components/ThemeToggle'; @@ -42,10 +43,10 @@ function VersionDisplay() { {!isChecking && updateStatus !== UpdateStatus.FETCH_FAILED && (
{updateStatus === UpdateStatus.HAS_UPDATE && ( diff --git a/src/components/DataMigration.tsx b/src/components/DataMigration.tsx new file mode 100644 index 0000000..2c40256 --- /dev/null +++ b/src/components/DataMigration.tsx @@ -0,0 +1,343 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +'use client'; + +import { AlertTriangle, Download, FileCheck, Lock, Upload } from 'lucide-react'; +import { useRef, useState } from 'react'; +import Swal from 'sweetalert2'; + +interface DataMigrationProps { + onRefreshConfig?: () => Promise; +} + +const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => { + const [exportPassword, setExportPassword] = useState(''); + const [importPassword, setImportPassword] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); + const [isExporting, setIsExporting] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const fileInputRef = useRef(null); + + // 导出数据 + const handleExport = async () => { + if (!exportPassword.trim()) { + Swal.fire({ + icon: 'error', + title: '错误', + text: '请输入加密密码', + returnFocus: false, + }); + return; + } + + try { + setIsExporting(true); + + const response = await fetch('/api/admin/data_migration/export', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + password: exportPassword, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `导出失败: ${response.status}`); + } + + // 获取文件名 + const contentDisposition = response.headers.get('content-disposition'); + const filenameMatch = contentDisposition?.match(/filename="(.+)"/); + const filename = filenameMatch?.[1] || 'moontv-backup.dat'; + + // 下载文件 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + a.style.position = 'fixed'; + a.style.top = '0'; + a.style.left = '0'; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + Swal.fire({ + icon: 'success', + title: '导出成功', + text: '数据已成功导出,请妥善保管备份文件和密码', + timer: 3000, + showConfirmButton: false, + returnFocus: false, + }); + + setExportPassword(''); + } catch (error) { + Swal.fire({ + icon: 'error', + title: '导出失败', + text: error instanceof Error ? error.message : '导出过程中发生错误', + returnFocus: false, + }); + } finally { + setIsExporting(false); + } + }; + + // 文件选择处理 + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + setSelectedFile(file); + } + }; + + // 导入数据 + const handleImport = async () => { + if (!selectedFile) { + Swal.fire({ + icon: 'error', + title: '错误', + text: '请选择备份文件', + returnFocus: false, + }); + return; + } + + if (!importPassword.trim()) { + Swal.fire({ + icon: 'error', + title: '错误', + text: '请输入解密密码', + returnFocus: false, + }); + return; + } + + try { + setIsImporting(true); + + const formData = new FormData(); + formData.append('file', selectedFile); + formData.append('password', importPassword); + + const response = await fetch('/api/admin/data_migration/import', { + method: 'POST', + body: formData, + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || `导入失败: ${response.status}`); + } + + await Swal.fire({ + icon: 'success', + title: '导入成功', + html: ` +
+

导入完成!

+

导入的用户数量: ${result.importedUsers}

+

备份时间: ${new Date(result.timestamp).toLocaleString('zh-CN')}

+

服务器版本: ${result.serverVersion || '未知版本'}

+

请刷新页面以查看最新数据。

+
+ `, + confirmButtonText: '刷新页面', + returnFocus: false, + }); + + // 清理状态 + setSelectedFile(null); + setImportPassword(''); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + + // 刷新配置 + if (onRefreshConfig) { + await onRefreshConfig(); + } + + // 刷新页面 + window.location.reload(); + } catch (error) { + Swal.fire({ + icon: 'error', + title: '导入失败', + text: error instanceof Error ? error.message : '导入过程中发生错误', + returnFocus: false, + }); + } finally { + setIsImporting(false); + } + }; + + return ( +
+ {/* 简洁警告提示 */} +
+ +

+ 数据迁移操作请谨慎,确保已备份重要数据 +

+
+ + {/* 主要操作区域 - 响应式布局 */} +
+ {/* 数据导出 */} +
+
+
+ +
+
+

数据导出

+

创建加密备份文件

+
+
+ +
+
+ {/* 密码输入 */} +
+ + setExportPassword(e.target.value)} + placeholder="设置强密码保护备份文件" + className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" + disabled={isExporting} + /> +

+ 导入时需要使用相同密码 +

+
+ + {/* 备份内容列表 */} +
+

备份内容:

+
+
• 管理配置
+
• 用户数据
+
• 播放记录
+
• 收藏夹
+
+
+
+ + {/* 导出按钮 */} + +
+
+ + {/* 数据导入 */} +
+
+
+ +
+
+

数据导入

+

⚠️ 将清空现有数据

+
+
+ +
+
+ {/* 文件选择 */} +
+ + +
+ + {/* 密码输入 */} +
+ + setImportPassword(e.target.value)} + placeholder="输入导出时的加密密码" + className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors" + disabled={isImporting} + /> +
+
+ + {/* 导入按钮 */} + +
+
+
+
+ ); +}; + +export default DataMigration; \ No newline at end of file diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index cb30060..e9c2454 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -18,7 +18,8 @@ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; -import { checkForUpdates, CURRENT_VERSION, UpdateStatus } from '@/lib/version'; +import { CURRENT_VERSION } from '@/lib/version'; +import { checkForUpdates, UpdateStatus } from '@/lib/version_check'; import { VersionPanel } from './VersionPanel'; @@ -433,10 +434,10 @@ export const UserMenu: React.FC = () => { {getRoleText(authInfo?.role || 'user')} @@ -517,10 +518,10 @@ export const UserMenu: React.FC = () => { updateStatus !== UpdateStatus.FETCH_FAILED && (
)} @@ -611,8 +612,8 @@ export const UserMenu: React.FC = () => { setIsDoubanDropdownOpen(false); }} className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanDataSource === option.value - ? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400' - : 'text-gray-900 dark:text-gray-100' + ? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400' + : 'text-gray-900 dark:text-gray-100' }`} > {option.label} @@ -716,8 +717,8 @@ export const UserMenu: React.FC = () => { setIsDoubanImageProxyDropdownOpen(false); }} className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanImageProxyType === option.value - ? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400' - : 'text-gray-900 dark:text-gray-100' + ? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400' + : 'text-gray-900 dark:text-gray-100' }`} > {option.label} diff --git a/src/components/VersionPanel.tsx b/src/components/VersionPanel.tsx index ce8ea15..b646753 100644 --- a/src/components/VersionPanel.tsx +++ b/src/components/VersionPanel.tsx @@ -16,7 +16,8 @@ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { changelog, ChangelogEntry } from '@/lib/changelog'; -import { compareVersions, CURRENT_VERSION, UpdateStatus } from '@/lib/version'; +import { CURRENT_VERSION } from '@/lib/version'; +import { compareVersions,UpdateStatus } from '@/lib/version_check'; interface VersionPanelProps { isOpen: boolean; diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..15c56d1 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,58 @@ +import CryptoJS from 'crypto-js'; + +/** + * 简单的对称加密工具 + * 使用 AES 加密算法 + */ +export class SimpleCrypto { + /** + * 加密数据 + * @param data 要加密的数据 + * @param password 加密密码 + * @returns 加密后的字符串 + */ + static encrypt(data: string, password: string): string { + try { + const encrypted = CryptoJS.AES.encrypt(data, password).toString(); + return encrypted; + } catch (error) { + throw new Error('加密失败'); + } + } + + /** + * 解密数据 + * @param encryptedData 加密的数据 + * @param password 解密密码 + * @returns 解密后的字符串 + */ + static decrypt(encryptedData: string, password: string): string { + try { + const bytes = CryptoJS.AES.decrypt(encryptedData, password); + const decrypted = bytes.toString(CryptoJS.enc.Utf8); + + if (!decrypted) { + throw new Error('解密失败,请检查密码是否正确'); + } + + return decrypted; + } catch (error) { + throw new Error('解密失败,请检查密码是否正确'); + } + } + + /** + * 验证密码是否能正确解密数据 + * @param encryptedData 加密的数据 + * @param password 密码 + * @returns 是否能正确解密 + */ + static canDecrypt(encryptedData: string, password: string): boolean { + try { + const decrypted = this.decrypt(encryptedData, password); + return decrypted.length > 0; + } catch { + return false; + } + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index b5f9eed..2622917 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -218,6 +218,15 @@ export class DbManager { } return {}; } + + // ---------- 数据清理 ---------- + async clearAllData(): Promise { + if (typeof (this.storage as any).clearAllData === 'function') { + await (this.storage as any).clearAllData(); + } else { + throw new Error('存储类型不支持清空数据操作'); + } + } } // 导出默认实例 diff --git a/src/lib/redis.db.ts b/src/lib/redis.db.ts index 0cebfe6..0e704fc 100644 --- a/src/lib/redis.db.ts +++ b/src/lib/redis.db.ts @@ -362,6 +362,27 @@ export class RedisStorage implements IStorage { return configs; } + + // 清空所有数据 + async clearAllData(): Promise { + try { + // 获取所有用户 + const allUsers = await this.getAllUsers(); + + // 删除所有用户及其数据 + for (const username of allUsers) { + await this.deleteUser(username); + } + + // 删除管理员配置 + await withRetry(() => this.client.del(this.adminConfigKey())); + + console.log('所有数据已清空'); + } catch (error) { + console.error('清空数据失败:', error); + throw new Error('清空数据失败'); + } + } } // 单例 Redis 客户端 diff --git a/src/lib/types.ts b/src/lib/types.ts index 7beba35..357691d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -79,6 +79,9 @@ export interface IStorage { ): Promise; deleteSkipConfig(userName: string, source: string, id: string): Promise; getAllSkipConfigs(userName: string): Promise<{ [key: string]: SkipConfig }>; + + // 数据清理相关 + clearAllData(): Promise; } // 搜索结果数据结构 diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index c7d4285..bfd1d32 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -343,6 +343,27 @@ export class UpstashRedisStorage implements IStorage { return configs; } + + // 清空所有数据 + async clearAllData(): Promise { + try { + // 获取所有用户 + const allUsers = await this.getAllUsers(); + + // 删除所有用户及其数据 + for (const username of allUsers) { + await this.deleteUser(username); + } + + // 删除管理员配置 + await withRetry(() => this.client.del(this.adminConfigKey())); + + console.log('所有数据已清空'); + } catch (error) { + console.error('清空数据失败:', error); + throw new Error('清空数据失败'); + } + } } // 单例 Upstash Redis 客户端 diff --git a/src/lib/version.ts b/src/lib/version.ts index 758ffa1..8fe17f6 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,151 +1,6 @@ /* eslint-disable no-console */ -'use client'; - const CURRENT_VERSION = '2.2.1'; -// 版本检查结果枚举 -export enum UpdateStatus { - HAS_UPDATE = 'has_update', // 有新版本 - NO_UPDATE = 'no_update', // 无新版本 - FETCH_FAILED = 'fetch_failed', // 获取失败 -} - -// 远程版本检查URL配置 -const VERSION_CHECK_URLS = [ - 'https://raw.githubusercontent.com/MoonTechLab/LunaTV/main/VERSION.txt', -]; - -/** - * 检查是否有新版本可用 - * @returns Promise - 返回版本检查状态 - */ -export async function checkForUpdates(): Promise { - try { - // 尝试从主要URL获取版本信息 - const primaryVersion = await fetchVersionFromUrl(VERSION_CHECK_URLS[0]); - if (primaryVersion) { - return compareVersions(primaryVersion); - } - - // 如果主要URL失败,尝试备用URL - const backupVersion = await fetchVersionFromUrl(VERSION_CHECK_URLS[1]); - if (backupVersion) { - return compareVersions(backupVersion); - } - - // 如果两个URL都失败,返回获取失败状态 - return UpdateStatus.FETCH_FAILED; - } catch (error) { - console.error('版本检查失败:', error); - return UpdateStatus.FETCH_FAILED; - } -} - -/** - * 从指定URL获取版本信息 - * @param url - 版本信息URL - * @returns Promise - 版本字符串或null - */ -async function fetchVersionFromUrl(url: string): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时 - - // 添加时间戳参数以避免缓存 - const timestamp = Date.now(); - const urlWithTimestamp = url.includes('?') - ? `${url}&_t=${timestamp}` - : `${url}?_t=${timestamp}`; - - const response = await fetch(urlWithTimestamp, { - method: 'GET', - signal: controller.signal, - headers: { - 'Content-Type': 'text/plain', - }, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const version = await response.text(); - return version.trim(); - } catch (error) { - console.warn(`从 ${url} 获取版本信息失败:`, error); - return null; - } -} - -/** - * 比较版本号 - * @param remoteVersion - 远程版本号 - * @returns UpdateStatus - 返回版本比较结果 - */ -function compareVersions(remoteVersion: string): UpdateStatus { - // 如果版本号相同,无需更新 - if (remoteVersion === CURRENT_VERSION) { - return UpdateStatus.NO_UPDATE; - } - - try { - // 解析版本号为数字数组 [X, Y, Z] - const currentParts = CURRENT_VERSION.split('.').map((part) => { - const num = parseInt(part, 10); - if (isNaN(num) || num < 0) { - throw new Error(`无效的版本号格式: ${CURRENT_VERSION}`); - } - return num; - }); - - const remoteParts = remoteVersion.split('.').map((part) => { - const num = parseInt(part, 10); - if (isNaN(num) || num < 0) { - throw new Error(`无效的版本号格式: ${remoteVersion}`); - } - return num; - }); - - // 标准化版本号到3个部分 - const normalizeVersion = (parts: number[]) => { - if (parts.length >= 3) { - return parts.slice(0, 3); // 取前三个元素 - } else { - // 不足3个的部分补0 - const normalized = [...parts]; - while (normalized.length < 3) { - normalized.push(0); - } - return normalized; - } - }; - - const normalizedCurrent = normalizeVersion(currentParts); - const normalizedRemote = normalizeVersion(remoteParts); - - // 逐级比较版本号 - for (let i = 0; i < 3; i++) { - if (normalizedRemote[i] > normalizedCurrent[i]) { - return UpdateStatus.HAS_UPDATE; - } else if (normalizedRemote[i] < normalizedCurrent[i]) { - return UpdateStatus.NO_UPDATE; - } - // 如果当前级别相等,继续比较下一级 - } - - // 所有级别都相等,无需更新 - return UpdateStatus.NO_UPDATE; - } catch (error) { - console.error('版本号比较失败:', error); - // 如果版本号格式无效,回退到字符串比较 - return remoteVersion !== CURRENT_VERSION - ? UpdateStatus.HAS_UPDATE - : UpdateStatus.NO_UPDATE; - } -} - // 导出当前版本号供其他地方使用 -export { compareVersions, CURRENT_VERSION }; +export { CURRENT_VERSION }; diff --git a/src/lib/version_check.ts b/src/lib/version_check.ts new file mode 100644 index 0000000..63f4386 --- /dev/null +++ b/src/lib/version_check.ts @@ -0,0 +1,148 @@ +/* eslint-disable no-console */ + +'use client'; + +import { CURRENT_VERSION } from "@/lib/version"; + +// 版本检查结果枚举 +export enum UpdateStatus { + HAS_UPDATE = 'has_update', // 有新版本 + NO_UPDATE = 'no_update', // 无新版本 + FETCH_FAILED = 'fetch_failed', // 获取失败 +} + +// 远程版本检查URL配置 +const VERSION_CHECK_URLS = [ + 'https://raw.githubusercontent.com/MoonTechLab/LunaTV/main/VERSION.txt', +]; + +/** + * 检查是否有新版本可用 + * @returns Promise - 返回版本检查状态 + */ +export async function checkForUpdates(): Promise { + try { + // 尝试从主要URL获取版本信息 + const primaryVersion = await fetchVersionFromUrl(VERSION_CHECK_URLS[0]); + if (primaryVersion) { + return compareVersions(primaryVersion); + } + + // 如果主要URL失败,尝试备用URL + const backupVersion = await fetchVersionFromUrl(VERSION_CHECK_URLS[1]); + if (backupVersion) { + return compareVersions(backupVersion); + } + + // 如果两个URL都失败,返回获取失败状态 + return UpdateStatus.FETCH_FAILED; + } catch (error) { + console.error('版本检查失败:', error); + return UpdateStatus.FETCH_FAILED; + } +} + +/** + * 从指定URL获取版本信息 + * @param url - 版本信息URL + * @returns Promise - 版本字符串或null + */ +async function fetchVersionFromUrl(url: string): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时 + + // 添加时间戳参数以避免缓存 + const timestamp = Date.now(); + const urlWithTimestamp = url.includes('?') + ? `${url}&_t=${timestamp}` + : `${url}?_t=${timestamp}`; + + const response = await fetch(urlWithTimestamp, { + method: 'GET', + signal: controller.signal, + headers: { + 'Content-Type': 'text/plain', + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const version = await response.text(); + return version.trim(); + } catch (error) { + console.warn(`从 ${url} 获取版本信息失败:`, error); + return null; + } +} + +/** + * 比较版本号 + * @param remoteVersion - 远程版本号 + * @returns UpdateStatus - 返回版本比较结果 + */ +export function compareVersions(remoteVersion: string): UpdateStatus { + // 如果版本号相同,无需更新 + if (remoteVersion === CURRENT_VERSION) { + return UpdateStatus.NO_UPDATE; + } + + try { + // 解析版本号为数字数组 [X, Y, Z] + const currentParts = CURRENT_VERSION.split('.').map((part) => { + const num = parseInt(part, 10); + if (isNaN(num) || num < 0) { + throw new Error(`无效的版本号格式: ${CURRENT_VERSION}`); + } + return num; + }); + + const remoteParts = remoteVersion.split('.').map((part) => { + const num = parseInt(part, 10); + if (isNaN(num) || num < 0) { + throw new Error(`无效的版本号格式: ${remoteVersion}`); + } + return num; + }); + + // 标准化版本号到3个部分 + const normalizeVersion = (parts: number[]) => { + if (parts.length >= 3) { + return parts.slice(0, 3); // 取前三个元素 + } else { + // 不足3个的部分补0 + const normalized = [...parts]; + while (normalized.length < 3) { + normalized.push(0); + } + return normalized; + } + }; + + const normalizedCurrent = normalizeVersion(currentParts); + const normalizedRemote = normalizeVersion(remoteParts); + + // 逐级比较版本号 + for (let i = 0; i < 3; i++) { + if (normalizedRemote[i] > normalizedCurrent[i]) { + return UpdateStatus.HAS_UPDATE; + } else if (normalizedRemote[i] < normalizedCurrent[i]) { + return UpdateStatus.NO_UPDATE; + } + // 如果当前级别相等,继续比较下一级 + } + + // 所有级别都相等,无需更新 + return UpdateStatus.NO_UPDATE; + } catch (error) { + console.error('版本号比较失败:', error); + // 如果版本号格式无效,回退到字符串比较 + return remoteVersion !== CURRENT_VERSION + ? UpdateStatus.HAS_UPDATE + : UpdateStatus.NO_UPDATE; + } +} \ No newline at end of file