diff --git a/next.config.js b/next.config.js index 9b71e89..c7d7653 100644 --- a/next.config.js +++ b/next.config.js @@ -10,7 +10,7 @@ const nextConfig = { swcMinify: true, experimental: { - instrumentationHook: true, + instrumentationHook: process.env.NODE_ENV === 'production', }, // Uncoment to add domain whitelist diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d564984..1bda760 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -35,6 +35,7 @@ import { } from 'lucide-react'; import { GripVertical } from 'lucide-react'; import { Suspense, useCallback, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; import Swal from 'sweetalert2'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; @@ -145,6 +146,13 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { username: '', password: '', }); + const [showConfigureApisModal, setShowConfigureApisModal] = useState(false); + const [selectedUser, setSelectedUser] = useState<{ + username: string; + role: 'user' | 'admin' | 'owner'; + enabledApis?: string[]; + } | null>(null); + const [selectedApis, setSelectedApis] = useState([]); // 当前登录用户名 const currentUsername = getAuthInfoFromBrowserCookie()?.username || null; @@ -209,6 +217,56 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { await handleUserAction('deleteUser', username); }; + const handleConfigureUserApis = (user: { + username: string; + role: 'user' | 'admin' | 'owner'; + enabledApis?: string[]; + }) => { + setSelectedUser(user); + setSelectedApis(user.enabledApis || []); + setShowConfigureApisModal(true); + }; + + // 提取URL域名的辅助函数 + const extractDomain = (url: string): string => { + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch { + // 如果URL格式不正确,返回原字符串 + return url; + } + }; + + const handleSaveUserApis = async () => { + if (!selectedUser) return; + + try { + const res = await fetch('/api/admin/user', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + targetUsername: selectedUser.username, + action: 'updateUserApis', + enabledApis: selectedApis, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `操作失败: ${res.status}`); + } + + // 成功后刷新配置 + await refreshConfig(); + setShowConfigureApisModal(false); + setSelectedUser(null); + setSelectedApis([]); + } catch (err) { + showError(err instanceof Error ? err.message : '操作失败'); + } + }; + // 通用请求函数 const handleUserAction = async ( action: @@ -394,6 +452,12 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { > 状态 + + 采集源权限 + { {!user.banned ? '正常' : '已封禁'} + +
+ + {user.enabledApis && user.enabledApis.length > 0 + ? `${user.enabledApis.length} 个源` + : '无限制'} + + {/* 配置采集源权限按钮 */} + {(role === 'owner' || + (role === 'admin' && + (user.role === 'user' || + user.username === currentUsername))) && ( + + )} +
+ {/* 修改密码按钮 */} {canChangePassword && ( @@ -542,6 +627,131 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { + + {/* 配置用户采集源权限弹窗 */} + {showConfigureApisModal && selectedUser && createPortal( +
+
+
+
+

+ 配置用户采集源权限 - {selectedUser.username} +

+ +
+ +
+
+
+ + + + + 配置说明 + +
+

+ 提示:全不选为无限制,选中的采集源将限制用户只能访问这些源 +

+
+
+ + {/* 采集源选择 - 多列布局 */} +
+

+ 选择可用的采集源: +

+
+ {config?.SourceConfig?.map((source) => ( + + ))} +
+
+ + {/* 快速操作按钮 */} +
+
+ + +
+
+ 已选择: + {selectedApis.length > 0 ? `${selectedApis.length} 个源` : '无限制'} + +
+
+ + {/* 操作按钮 */} +
+ + +
+
+
+
, + document.body + )} ); }; diff --git a/src/app/api/admin/user/route.ts b/src/app/api/admin/user/route.ts index aaa012e..ff5c695 100644 --- a/src/app/api/admin/user/route.ts +++ b/src/app/api/admin/user/route.ts @@ -17,6 +17,7 @@ const ACTIONS = [ 'cancelAdmin', 'changePassword', 'deleteUser', + 'updateUserApis', ] as const; export async function POST(request: NextRequest) { @@ -60,6 +61,7 @@ export async function POST(request: NextRequest) { if ( action !== 'changePassword' && action !== 'deleteUser' && + action !== 'updateUserApis' && username === targetUsername ) { return NextResponse.json( @@ -273,6 +275,38 @@ export async function POST(request: NextRequest) { break; } + case 'updateUserApis': { + if (!targetEntry) { + return NextResponse.json( + { error: '目标用户不存在' }, + { status: 404 } + ); + } + + const { enabledApis } = body as { enabledApis?: string[] }; + + // 权限检查:站长可配置所有人的采集源,管理员可配置普通用户和自己的采集源 + if ( + isTargetAdmin && + operatorRole !== 'owner' && + username !== targetUsername + ) { + return NextResponse.json( + { error: '仅站长可配置其他管理员的采集源' }, + { status: 401 } + ); + } + + // 更新用户的采集源权限 + if (enabledApis && enabledApis.length > 0) { + targetEntry.enabledApis = enabledApis; + } else { + // 如果为空数组或未提供,则删除该字段,表示无限制 + delete targetEntry.enabledApis; + } + + break; + } default: return NextResponse.json({ error: '未知操作' }, { status: 400 }); } diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index e5c9916..42ce3d7 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -221,7 +221,7 @@ async function verifyDevice(): Promise { if (!apiResp.success) { console.error('❌ 设备验证失败'); console.error(`验证失败原因: ${apiResp.message}`); - process.exit(1); + process.exit(0); } // 重置网络失败计数 @@ -244,13 +244,13 @@ async function verifyDevice(): Promise { if (networkFailureCount >= MAX_NETWORK_FAILURES) { console.error('❌ 网络验证失败次数超过限制,重置认证信息'); - process.exit(1); + process.exit(0); } } else { // 非网络错误,直接退出 console.error('❌ 设备验证失败'); console.error(`验证失败原因: ${errorMessage}`); - process.exit(1); + process.exit(0); } } } @@ -298,7 +298,7 @@ async function initializeDeviceAuth(): Promise { }); } catch (keyError) { console.error('❌ 公钥KeyObject创建失败:', keyError); - process.exit(1); + process.exit(0); } expectedFingerprint = fingerprint; @@ -307,7 +307,7 @@ async function initializeDeviceAuth(): Promise { console.log('🔑 设备认证信息初始化成功'); } catch (error) { console.error('❌ 设备认证信息初始化失败:', error); - process.exit(1); + process.exit(0); } } diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 64c65aa..99e4cf4 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -1,11 +1,17 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { getAvailableApiSites, getCacheTime } from '@/lib/config'; import { getDetailFromApi } from '@/lib/downstream'; +import { getAuthInfoFromCookie } from '@/lib/auth'; export const runtime = 'nodejs'; -export async function GET(request: Request) { +export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const { searchParams } = new URL(request.url); const id = searchParams.get('id'); const sourceCode = searchParams.get('source'); @@ -19,7 +25,7 @@ export async function GET(request: Request) { } try { - const apiSites = await getAvailableApiSites(); + const apiSites = await getAvailableApiSites(authInfo.username); const apiSite = apiSites.find((site) => site.key === sourceCode); if (!apiSite) { diff --git a/src/app/api/search/one/route.ts b/src/app/api/search/one/route.ts index 583ed4e..ff0d97a 100644 --- a/src/app/api/search/one/route.ts +++ b/src/app/api/search/one/route.ts @@ -1,13 +1,19 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; -import { getCacheTime, getConfig } from '@/lib/config'; +import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; +import { getAuthInfoFromCookie } from '@/lib/auth'; import { yellowWords } from '@/lib/yellow'; export const runtime = 'nodejs'; // OrionTV 兼容接口 -export async function GET(request: Request) { +export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const { searchParams } = new URL(request.url); const query = searchParams.get('q'); const resourceId = searchParams.get('resourceId'); @@ -28,7 +34,7 @@ export async function GET(request: Request) { } const config = await getConfig(); - const apiSites = config.SourceConfig.filter((site) => !site.disabled); + const apiSites = await getAvailableApiSites(authInfo.username); try { // 根据 resourceId 查找对应的 API 站点 diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index aebe707..f61eadd 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,14 +1,20 @@ /* eslint-disable @typescript-eslint/no-explicit-any,no-console */ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; -import { getCacheTime, getConfig } from '@/lib/config'; +import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; import { yellowWords } from '@/lib/yellow'; +import { getAuthInfoFromCookie } from '@/lib/auth'; export const runtime = 'nodejs'; -export async function GET(request: Request) { +export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const { searchParams } = new URL(request.url); const query = searchParams.get('q'); @@ -28,7 +34,7 @@ export async function GET(request: Request) { } const config = await getConfig(); - const apiSites = config.SourceConfig.filter((site) => !site.disabled); + const apiSites = await getAvailableApiSites(authInfo.username); // 添加超时控制和错误处理,避免慢接口拖累整体响应 const searchPromises = apiSites.map((site) => diff --git a/src/app/api/search/suggestions/route.ts b/src/app/api/search/suggestions/route.ts index 4a101d9..098b276 100644 --- a/src/app/api/search/suggestions/route.ts +++ b/src/app/api/search/suggestions/route.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; -import { getConfig } from '@/lib/config'; +import { getAvailableApiSites, getConfig } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; export const runtime = 'nodejs'; @@ -12,7 +12,7 @@ export async function GET(request: NextRequest) { try { // 从 cookie 获取用户信息 const authInfo = getAuthInfoFromCookie(request); - if (!authInfo) { + if (!authInfo || !authInfo.username) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -35,7 +35,7 @@ export async function GET(request: NextRequest) { } // 生成建议 - const suggestions = await generateSuggestions(query); + const suggestions = await generateSuggestions(query, authInfo.username); // 从配置中获取缓存时间,如果没有配置则使用默认值300秒(5分钟) const cacheTime = config.SiteConfig.SiteInterfaceCacheTime || 300; @@ -57,7 +57,7 @@ export async function GET(request: NextRequest) { } } -async function generateSuggestions(query: string): Promise< +async function generateSuggestions(query: string, username: string): Promise< Array<{ text: string; type: 'exact' | 'related' | 'suggestion'; @@ -66,8 +66,7 @@ async function generateSuggestions(query: string): Promise< > { const queryLower = query.toLowerCase(); - const config = await getConfig(); - const apiSites = config.SourceConfig.filter((site: any) => !site.disabled); + const apiSites = await getAvailableApiSites(username); let realKeywords: string[] = []; if (apiSites.length > 0) { diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index fb5f23b..c5f9930 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -1,14 +1,20 @@ /* eslint-disable @typescript-eslint/no-explicit-any,no-console */ -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; -import { getConfig } from '@/lib/config'; +import { getAvailableApiSites, getConfig } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; import { yellowWords } from '@/lib/yellow'; +import { getAuthInfoFromCookie } from '@/lib/auth'; export const runtime = 'nodejs'; export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const { searchParams } = new URL(request.url); const query = searchParams.get('q'); @@ -25,7 +31,7 @@ export async function GET(request: NextRequest) { } const config = await getConfig(); - const apiSites = config.SourceConfig.filter((site) => !site.disabled); + const apiSites = await getAvailableApiSites(authInfo.username); // 共享状态 let streamClosed = false; diff --git a/src/instrumentation.ts b/src/instrumentation.ts index b8fa53a..96f4349 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -285,7 +285,7 @@ function checkEnvironment(): void { if (!username || username.trim() === '') { console.error('❌ USERNAME 环境变量不得为空'); console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } // 检查 PASSWORD @@ -293,7 +293,7 @@ function checkEnvironment(): void { if (!password || password.trim() === '') { console.error('❌ PASSWORD 环境变量不得为空'); console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } // 检查弱密码 @@ -330,20 +330,20 @@ function checkEnvironment(): void { if (weakPasswords.includes(password.toLowerCase())) { console.error(`❌ PASSWORD 不能使用常见弱密码: ${password}`); console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } if (password.length < 8) { console.error('❌ PASSWORD 长度不能少于8位'); console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } // 检查密码不能与用户名相同 if (password.toLowerCase() === username.toLowerCase()) { console.error('❌ PASSWORD 不能与 USERNAME 相同'); console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } // 检查 AUTH_TOKEN @@ -351,7 +351,7 @@ function checkEnvironment(): void { if (!authToken || authToken.trim() === '') { console.error('❌ AUTH_TOKEN 不得为空'); console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } // 检查 AUTH_SERVER(可选,但如果设置了需要验证格式) @@ -360,7 +360,7 @@ function checkEnvironment(): void { if (!authServer.startsWith('https://') && !authServer.startsWith('http://')) { console.error('❌ AUTH_SERVER 必须以 http:// 或 https:// 开头'); console.error('🚨 环境变量检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } } } @@ -377,7 +377,7 @@ async function checkAuthentication(): Promise { if (!authToken || !username || !password) { console.error('❌ 认证检查失败:缺少必需的环境变量'); console.error('🚨 认证检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } try { @@ -419,7 +419,7 @@ async function checkAuthentication(): Promise { } catch (error) { console.error('❌ 认证流程失败:', error instanceof Error ? error.message : '未知错误'); console.error('🚨 认证检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } } @@ -434,7 +434,7 @@ function checkDatabaseConfig(): void { if (!allowedStorageTypes.includes(storageType)) { console.error(`❌ NEXT_PUBLIC_STORAGE_TYPE 必须是 ${allowedStorageTypes.join(', ')} 之一,当前值: ${storageType}`); console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } // 根据存储类型检查相应的环境变量 @@ -444,12 +444,12 @@ function checkDatabaseConfig(): void { if (!kvrocksUrl || kvrocksUrl.trim() === '') { console.error('❌ KVROCKS_URL 环境变量不得为空'); console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } if (!kvrocksUrl.startsWith('redis://')) { console.error('❌ KVROCKS_URL 必须以 redis:// 开头'); console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } break; @@ -460,18 +460,18 @@ function checkDatabaseConfig(): void { if (!upstashUrl || upstashUrl.trim() === '') { console.error('❌ UPSTASH_URL 环境变量不得为空'); console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } if (!upstashUrl.startsWith('https://')) { console.error('❌ UPSTASH_URL 必须以 https:// 开头'); console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } if (!upstashToken || upstashToken.trim() === '') { console.error('❌ UPSTASH_TOKEN 环境变量不得为空'); console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } break; @@ -480,12 +480,12 @@ function checkDatabaseConfig(): void { if (!redisUrl || redisUrl.trim() === '') { console.error('❌ REDIS_URL 环境变量不得为空'); console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } if (!redisUrl.startsWith('redis://')) { console.error('❌ REDIS_URL 必须以 redis:// 开头'); console.error('🚨 数据库配置检查失败,服务器即将退出'); - process.exit(1); + process.exit(0); } break; } @@ -540,7 +540,7 @@ export async function register() { } catch (error) { console.error('💥 启动检查过程中发生未预期错误:', error); console.error('🚨 服务器即将退出'); - process.exit(1); + process.exit(0); } } } diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 0179294..0c762e9 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -22,6 +22,7 @@ export interface AdminConfig { username: string; role: 'user' | 'admin' | 'owner'; banned?: boolean; + enabledApis?: string[]; // 为空则允许全部 }[]; }; SourceConfig: { diff --git a/src/lib/config.ts b/src/lib/config.ts index be70cb6..c1cc706 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -275,6 +275,7 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { return true; }); // 过滤站长 + const originOwnerCfg = adminConfig.UserConfig.Users.find((u) => u.username === ownerUser); adminConfig.UserConfig.Users = adminConfig.UserConfig.Users.filter((user) => user.username !== ownerUser); // 其他用户不得拥有 owner 权限 adminConfig.UserConfig.Users.forEach((user) => { @@ -287,6 +288,7 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { username: ownerUser!, role: 'owner', banned: false, + enabledApis: originOwnerCfg?.enabledApis || undefined, }); // 采集源去重 @@ -333,9 +335,15 @@ export async function getCacheTime(): Promise { return config.SiteConfig.SiteInterfaceCacheTime || 7200; } -export async function getAvailableApiSites(): Promise { +export async function getAvailableApiSites(user?: string): Promise { const config = await getConfig(); - return config.SourceConfig.filter((s) => !s.disabled).map((s) => ({ + const allApiSites = config.SourceConfig.filter((s) => !s.disabled); + const userApiSites = user ? config.UserConfig.Users.find((u) => u.username === user)?.enabledApis || [] : []; + if (userApiSites.length === 0) { + return allApiSites; + } + const userApiSitesSet = new Set(userApiSites); + return allApiSites.filter((s) => userApiSitesSet.has(s.key)).map((s) => ({ key: s.key, name: s.name, api: s.api,