diff --git a/migrations/004_add_tvbox_subscribe_token.sql b/migrations/004_add_tvbox_subscribe_token.sql new file mode 100644 index 0000000..9918900 --- /dev/null +++ b/migrations/004_add_tvbox_subscribe_token.sql @@ -0,0 +1,11 @@ +-- ============================================ +-- 添加 TVBox 订阅 token 字段 +-- 版本: 004 +-- 创建时间: 2026-02-25 +-- ============================================ + +-- 为 users 表添加 tvbox_subscribe_token 字段 +ALTER TABLE users ADD COLUMN tvbox_subscribe_token TEXT; + +-- 创建索引以加速 token 查询 +CREATE INDEX IF NOT EXISTS idx_users_tvbox_token ON users(tvbox_subscribe_token) WHERE tvbox_subscribe_token IS NOT NULL; diff --git a/migrations/postgres/004_add_tvbox_subscribe_token.sql b/migrations/postgres/004_add_tvbox_subscribe_token.sql new file mode 100644 index 0000000..ea9e9d9 --- /dev/null +++ b/migrations/postgres/004_add_tvbox_subscribe_token.sql @@ -0,0 +1,14 @@ +-- ============================================ +-- 添加 TVBox 订阅 token 字段 +-- 版本: 004 +-- 创建时间: 2026-02-25 +-- ============================================ + +-- 为 users 表添加 tvbox_subscribe_token 字段 +ALTER TABLE users ADD COLUMN IF NOT EXISTS tvbox_subscribe_token TEXT; + +-- 创建索引以加速 token 查询 +CREATE INDEX IF NOT EXISTS idx_users_tvbox_token ON users(tvbox_subscribe_token) WHERE tvbox_subscribe_token IS NOT NULL; + +-- 添加注释 +COMMENT ON COLUMN users.tvbox_subscribe_token IS 'TVBox订阅token,用于用户独立订阅'; diff --git a/src/app/api/emby/cms-proxy/[token]/route.ts b/src/app/api/emby/cms-proxy/[token]/route.ts index 77a4082..b1b4a1f 100644 --- a/src/app/api/emby/cms-proxy/[token]/route.ts +++ b/src/app/api/emby/cms-proxy/[token]/route.ts @@ -29,11 +29,29 @@ export async function GET( ); } - // 验证 TVBox Token + // 验证 TVBox Token(从路径中获取) const requestToken = params.token; - const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN; + const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN; - if (!subscribeToken || requestToken !== subscribeToken) { + // 检查是否是全局token或用户token + let isValidToken = false; + if (globalToken && requestToken === globalToken) { + // 全局token + isValidToken = true; + } else { + // 检查是否是用户token + const { db } = await import('@/lib/db'); + const username = await db.getUsernameByTvboxToken(requestToken); + if (username) { + // 检查用户是否被封禁 + const userInfo = await db.getUserInfoV2(username); + if (userInfo && !userInfo.banned) { + isValidToken = true; + } + } + } + + if (!isValidToken) { return NextResponse.json({ code: 401, msg: '无效的访问token', diff --git a/src/app/api/emby/play/[token]/[filename]/route.ts b/src/app/api/emby/play/[token]/[filename]/route.ts index 7518487..7c0317d 100644 --- a/src/app/api/emby/play/[token]/[filename]/route.ts +++ b/src/app/api/emby/play/[token]/[filename]/route.ts @@ -35,13 +35,29 @@ export async function GET( try { const { searchParams } = new URL(request.url); - // 双重验证:TVBox Token 或 用户登录 + // 双重验证:TVBox Token(全局或用户) 或 用户登录 const requestToken = params.token; - const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN; + const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN; const authInfo = getAuthInfoFromCookie(request); - // 验证 TVBox Token - const hasValidToken = subscribeToken && requestToken === subscribeToken; + // 验证 TVBox Token(全局token或用户token) + let hasValidToken = false; + if (globalToken && requestToken === globalToken) { + // 全局token + hasValidToken = true; + } else { + // 检查是否是用户token + const { db } = await import('@/lib/db'); + const username = await db.getUsernameByTvboxToken(requestToken); + if (username) { + // 检查用户是否被封禁 + const userInfo = await db.getUserInfoV2(username); + if (userInfo && !userInfo.banned) { + hasValidToken = true; + } + } + } + // 验证用户登录 const hasValidAuth = authInfo && authInfo.username; diff --git a/src/app/api/openlist/cms-proxy/[token]/route.ts b/src/app/api/openlist/cms-proxy/[token]/route.ts index 97d2834..be3f8e9 100644 --- a/src/app/api/openlist/cms-proxy/[token]/route.ts +++ b/src/app/api/openlist/cms-proxy/[token]/route.ts @@ -30,9 +30,27 @@ export async function GET( // 验证 TVBox Token(从路径中获取) const requestToken = params.token; - const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN; + const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN; - if (!subscribeToken || requestToken !== subscribeToken) { + // 检查是否是全局token或用户token + let isValidToken = false; + if (globalToken && requestToken === globalToken) { + // 全局token + isValidToken = true; + } else { + // 检查是否是用户token + const { db } = await import('@/lib/db'); + const username = await db.getUsernameByTvboxToken(requestToken); + if (username) { + // 检查用户是否被封禁 + const userInfo = await db.getUserInfoV2(username); + if (userInfo && !userInfo.banned) { + isValidToken = true; + } + } + } + + if (!isValidToken) { return NextResponse.json( { code: 401, diff --git a/src/app/api/openlist/play/[token]/route.ts b/src/app/api/openlist/play/[token]/route.ts index 7b51566..5a3eb13 100644 --- a/src/app/api/openlist/play/[token]/route.ts +++ b/src/app/api/openlist/play/[token]/route.ts @@ -22,13 +22,29 @@ export async function GET( try { const { searchParams } = new URL(request.url); - // 双重验证:TVBox Token 或 用户登录 + // 双重验证:TVBox Token(全局或用户) 或 用户登录 const requestToken = params.token; - const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN; + const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN; const authInfo = getAuthInfoFromCookie(request); - // 验证 TVBox Token - const hasValidToken = subscribeToken && requestToken === subscribeToken; + // 验证 TVBox Token(全局token或用户token) + let hasValidToken = false; + if (globalToken && requestToken === globalToken) { + // 全局token + hasValidToken = true; + } else { + // 检查是否是用户token + const { db } = await import('@/lib/db'); + const username = await db.getUsernameByTvboxToken(requestToken); + if (username) { + // 检查用户是否被封禁 + const userInfo = await db.getUserInfoV2(username); + if (userInfo && !userInfo.banned) { + hasValidToken = true; + } + } + } + // 验证用户登录 const hasValidAuth = authInfo && authInfo.username; diff --git a/src/app/api/tvbox/subscribe/route.ts b/src/app/api/tvbox/subscribe/route.ts index 4e55f59..686ada6 100644 --- a/src/app/api/tvbox/subscribe/route.ts +++ b/src/app/api/tvbox/subscribe/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAvailableApiSites, getConfig } from '@/lib/config'; +import { db } from '@/lib/db'; import { getCachedLiveChannels } from '@/lib/live'; export const runtime = 'nodejs'; @@ -10,6 +11,7 @@ export const runtime = 'nodejs'; /** * TVBOX订阅API * 根据视频源和直播源生成TVBOX订阅 + * 支持全局token(管理员)和用户token(普通用户) */ export async function GET(request: NextRequest) { // 检查是否开启订阅功能 @@ -24,22 +26,53 @@ export async function GET(request: NextRequest) { // 验证token const searchParams = request.nextUrl.searchParams; const token = searchParams.get('token'); - const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN; + const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN; const adFilter = searchParams.get('adFilter') === 'true'; // 获取去广告参数 - if (!subscribeToken || token !== subscribeToken) { + if (!token) { return NextResponse.json( - { error: '无效的订阅token' }, + { error: '缺少订阅token' }, { status: 401 } ); } + // 判断是全局token还是用户token + let username: string | undefined; + let isGlobalToken = false; + + if (globalToken && token === globalToken) { + // 全局token(管理员订阅) + isGlobalToken = true; + console.log('使用全局token访问TVBox订阅'); + } else { + // 用户token,查询用户名 + username = await db.getUsernameByTvboxToken(token) || undefined; + if (!username) { + return NextResponse.json( + { error: '无效的订阅token' }, + { status: 401 } + ); + } + + // 检查用户是否被封禁 + const userInfo = await db.getUserInfoV2(username); + if (userInfo?.banned) { + return NextResponse.json( + { error: '用户已被封禁' }, + { status: 403 } + ); + } + + console.log(`用户 ${username} 访问TVBox订阅`); + } + try { // 获取配置 const config = await getConfig(); - // 获取视频源(TVBox客户端不会发送Cookie,直接获取所有启用的源) - const apiSites = await getAvailableApiSites(); + // 获取视频源 + // 全局token返回所有源,用户token返回该用户有权限的源 + const apiSites = await getAvailableApiSites(username); // 获取直播源 const liveConfig = config.LiveConfig?.filter(live => !live.disabled) || []; @@ -75,7 +108,7 @@ export async function GET(request: NextRequest) { key: 'openlist', name: '私人影库', type: 1, - api: `${baseUrl}/api/openlist/cms-proxy/${encodeURIComponent(subscribeToken)}`, + api: `${baseUrl}/api/openlist/cms-proxy/${encodeURIComponent(token)}`, searchable: 1, quickSearch: 1, filterable: 1, @@ -87,7 +120,7 @@ export async function GET(request: NextRequest) { key: `emby_${source.key}`, name: source.name || 'Emby媒体库', type: 1, - api: `${baseUrl}/api/emby/cms-proxy/${encodeURIComponent(subscribeToken)}?embyKey=${source.key}`, + api: `${baseUrl}/api/emby/cms-proxy/${encodeURIComponent(token)}?embyKey=${source.key}`, searchable: 1, quickSearch: 1, filterable: 1, diff --git a/src/app/api/user/tvbox-token/reset/route.ts b/src/app/api/user/tvbox-token/reset/route.ts new file mode 100644 index 0000000..7227341 --- /dev/null +++ b/src/app/api/user/tvbox-token/reset/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { generateTvboxToken } from '@/lib/tvbox-token'; + +export const runtime = 'nodejs'; + +/** + * 重置用户的TVBox订阅token + * 旧token将失效 + */ +export async function POST(request: NextRequest) { + try { + // 验证用户登录 + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo?.username) { + return NextResponse.json( + { error: '未登录' }, + { status: 401 } + ); + } + + const username = authInfo.username; + + // 生成新token + const newToken = generateTvboxToken(); + await db.setTvboxSubscribeToken(username, newToken); + + console.log(`用户 ${username} 重置了TVBox订阅token`); + + return NextResponse.json({ + token: newToken, + message: '订阅token已重置,旧链接已失效', + }); + } catch (error) { + console.error('重置TVBox订阅token失败:', error); + return NextResponse.json( + { + error: '重置订阅token失败', + details: (error as Error).message, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/user/tvbox-token/route.ts b/src/app/api/user/tvbox-token/route.ts new file mode 100644 index 0000000..21eb4ed --- /dev/null +++ b/src/app/api/user/tvbox-token/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { generateTvboxToken } from '@/lib/tvbox-token'; + +export const runtime = 'nodejs'; + +/** + * 获取用户的TVBox订阅token + * 如果用户没有token,自动生成一个 + */ +export async function GET(request: NextRequest) { + try { + // 验证用户登录 + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo?.username) { + return NextResponse.json( + { error: '未登录' }, + { status: 401 } + ); + } + + const username = authInfo.username; + + // 获取token,如果没有则生成 + let token = await db.getTvboxSubscribeToken(username); + + if (!token) { + // 懒加载:首次访问时生成token + token = generateTvboxToken(); + await db.setTvboxSubscribeToken(username, token); + console.log(`为用户 ${username} 生成TVBox订阅token`); + } + + return NextResponse.json({ token }); + } catch (error) { + console.error('获取TVBox订阅token失败:', error); + return NextResponse.json( + { + error: '获取订阅token失败', + details: (error as Error).message, + }, + { status: 500 } + ); + } +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 81b5b5b..9022f9b 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -76,8 +76,12 @@ export const UserMenu: React.FC = () => { // 订阅相关状态 const [subscribeEnabled, setSubscribeEnabled] = useState(false); const [subscribeUrl, setSubscribeUrl] = useState(''); + const [subscribeUrlWithAdFilter, setSubscribeUrlWithAdFilter] = useState(''); const [copySuccess, setCopySuccess] = useState(false); - const [adFilterEnabled, setAdFilterEnabled] = useState(true); // 去广告开关,默认开启 + const [copySuccessAdFilter, setCopySuccessAdFilter] = useState(false); + const [tvboxToken, setTvboxToken] = useState(''); + const [isResettingToken, setIsResettingToken] = useState(false); + const [isLoadingSubscribeUrl, setIsLoadingSubscribeUrl] = useState(false); // Body 滚动锁定 - 使用 overflow 方式避免布局问题 useEffect(() => { @@ -304,20 +308,91 @@ export const UserMenu: React.FC = () => { }, []); // 懒加载订阅 URL - 只在打开订阅面板时请求 - const fetchSubscribeUrl = async (adFilter?: boolean) => { + const fetchSubscribeUrl = async () => { + setIsLoadingSubscribeUrl(true); try { - const currentOrigin = window.location.origin; - const filterValue = adFilter !== undefined ? adFilter : adFilterEnabled; - const response = await fetch(`/api/tvbox/config?origin=${encodeURIComponent(currentOrigin)}&adFilter=${filterValue}`); + // 获取用户的 TVBox token + const response = await fetch('/api/user/tvbox-token'); if (response.ok) { const data = await response.json(); - setSubscribeUrl(data.url); + const token = data.token; + setTvboxToken(token); + + // 前端拼接订阅链接 + const currentOrigin = window.location.origin; + const standardUrl = `${currentOrigin}/api/tvbox/subscribe?token=${token}`; + const adFilterUrl = `${currentOrigin}/api/tvbox/subscribe?token=${token}&adFilter=true`; + + setSubscribeUrl(standardUrl); + setSubscribeUrlWithAdFilter(adFilterUrl); } } catch (error) { console.error('获取订阅URL失败:', error); + } finally { + setIsLoadingSubscribeUrl(false); } }; + // 重置 TVBox token + const handleResetToken = async () => { + setConfirmDialog({ + isOpen: true, + title: '重置订阅Token', + message: '确定要重置订阅token吗?重置后旧的订阅链接将失效。', + onConfirm: async () => { + setConfirmDialog({ ...confirmDialog, isOpen: false }); + setIsResettingToken(true); + + try { + const response = await fetch('/api/user/tvbox-token/reset', { + method: 'POST', + }); + + const messageEl = document.getElementById('tvbox-token-message'); + if (response.ok) { + const data = await response.json(); + const token = data.token; + setTvboxToken(token); + + // 更新订阅链接 + const currentOrigin = window.location.origin; + const standardUrl = `${currentOrigin}/api/tvbox/subscribe?token=${token}`; + const adFilterUrl = `${currentOrigin}/api/tvbox/subscribe?token=${token}&adFilter=true`; + + setSubscribeUrl(standardUrl); + setSubscribeUrlWithAdFilter(adFilterUrl); + + if (messageEl) { + messageEl.textContent = '订阅token已重置!'; + messageEl.className = 'text-xs text-center text-green-600 dark:text-green-400 mt-2'; + messageEl.classList.remove('hidden'); + setTimeout(() => { + messageEl.classList.add('hidden'); + }, 3000); + } + } else { + const data = await response.json(); + if (messageEl) { + messageEl.textContent = data.error || '重置失败,请重试'; + messageEl.className = 'text-xs text-center text-red-600 dark:text-red-400 mt-2'; + messageEl.classList.remove('hidden'); + } + } + } catch (error) { + console.error('重置token失败:', error); + const messageEl = document.getElementById('tvbox-token-message'); + if (messageEl) { + messageEl.textContent = '重置失败,请重试'; + messageEl.className = 'text-xs text-center text-red-600 dark:text-red-400 mt-2'; + messageEl.classList.remove('hidden'); + } + } finally { + setIsResettingToken(false); + } + }, + }); + }; + // 获取认证信息和存储类型 useEffect(() => { if (typeof window !== 'undefined') { @@ -726,12 +801,7 @@ export const UserMenu: React.FC = () => { const handleCloseSubscribe = () => { setIsSubscribeOpen(false); setCopySuccess(false); - }; - - const handleAdFilterToggle = async (checked: boolean) => { - setAdFilterEnabled(checked); - // 当去广告开关改变时,重新获取订阅URL,传入新的值 - await fetchSubscribeUrl(checked); + setCopySuccessAdFilter(false); }; const handleCopySubscribeUrl = async () => { @@ -746,6 +816,18 @@ export const UserMenu: React.FC = () => { } }; + const handleCopySubscribeUrlWithAdFilter = async () => { + try { + await navigator.clipboard.writeText(subscribeUrlWithAdFilter); + setCopySuccessAdFilter(true); + setTimeout(() => { + setCopySuccessAdFilter(false); + }, 2000); + } catch (error) { + console.error('复制失败:', error); + } + }; + const handleSubmitChangePassword = async () => { setPasswordError(''); @@ -2527,7 +2609,7 @@ export const UserMenu: React.FC = () => { {/* 标题栏 */}