From 117a934db7844d5e4ddf92264170e84556c815a9 Mon Sep 17 00:00:00 2001 From: liiider Date: Mon, 9 Feb 2026 15:20:07 +0800 Subject: [PATCH 01/22] feat: Add Vercel KV support & external subscription config --- README.md | 19 +++++++++++++++++++ src/lib/upstash.db.ts | 6 +++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ca35b810b3..8de098a847 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,25 @@ 本项目**仅支持 Docker 或其他基于 Docker 的平台** 部署。 +### Vercel 一键部署 (推荐) + +点击下方按钮即可一键部署到 Vercel: + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fliiider%2FLunaTV&env=USERNAME,PASSWORD,NEXT_PUBLIC_SITE_NAME,NEXT_PUBLIC_STORAGE_TYPE,UPSTASH_URL,UPSTASH_TOKEN&envDescription=%E8%AF%B7%E5%8A%A1%E5%BF%85%E5%A1%AB%E5%86%99%E6%89%80%E6%9C%89%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%EF%BC%8C%E5%90%A6%E5%88%99%E6%97%A0%E6%B3%95%E6%AD%A3%E5%B8%B8%E8%BF%90%E8%A1%8C%E3%80%82%E5%AD%98%E5%82%A8%E7%B1%BB%E5%9E%8B%E8%AF%B7%E5%A1%AB%20upstash&project-name=moontv&repository-name=moontv) + +**Vercel 部署注意**: +- 由于 Vercel 是 Serverless 环境,推荐使用 **Upstash Redis** 作为数据存储以获得完整体验。 +- **方案 A (推荐):使用 Vercel KV (Redis)** + 1. 在 Vercel 项目设置页,点击 **Storage** -> **Create Database** -> **KV** (选择 Upstash Redis)。 + 2. 创建成功后,Vercel 会自动注入环境变量 (`KV_REST_API_URL` 等)。 + 3. 项目会自动识别并使用该数据库,无需额外配置。 + 4. **优势**:完整功能(多端同步、后台管理配置可保存),免费额度足够个人使用。 + +- **方案 B (Lite 模式 - 仅本地)**: + - 环境变量 `NEXT_PUBLIC_STORAGE_TYPE` 填 `localstorage`。 + - **无需** Upstash Redis。 + - **限制**:数据仅保存在当前浏览器,无法多端同步;管理后台修改的配置无法持久保存(重启失效);直播源无法自动更新。 + ### zeabur 一键部署 点击下方按钮即可一键部署,自动配置 LunaTV + Kvrocks 数据库: diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index bfd1d32394..abf751068d 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -372,12 +372,12 @@ function getUpstashRedisClient(): Redis { let client: Redis | undefined = (global as any)[globalKey]; if (!client) { - const upstashUrl = process.env.UPSTASH_URL; - const upstashToken = process.env.UPSTASH_TOKEN; + const upstashUrl = process.env.UPSTASH_URL || process.env.KV_REST_API_URL; + const upstashToken = process.env.UPSTASH_TOKEN || process.env.KV_REST_API_TOKEN; if (!upstashUrl || !upstashToken) { throw new Error( - 'UPSTASH_URL and UPSTASH_TOKEN env variables must be set' + 'UPSTASH_URL and UPSTASH_TOKEN (or KV_REST_API_URL and KV_REST_API_TOKEN) env variables must be set' ); } From bd41aaecce400d94f8bbe79be7b8e1ba0004c561 Mon Sep 17 00:00:00 2001 From: liiider Date: Mon, 9 Feb 2026 15:47:12 +0800 Subject: [PATCH 02/22] fix: Add support for Vercel Marketplace Upstash env vars --- src/lib/upstash.db.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index abf751068d..cdfae7fbad 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -372,12 +372,12 @@ function getUpstashRedisClient(): Redis { let client: Redis | undefined = (global as any)[globalKey]; if (!client) { - const upstashUrl = process.env.UPSTASH_URL || process.env.KV_REST_API_URL; - const upstashToken = process.env.UPSTASH_TOKEN || process.env.KV_REST_API_TOKEN; + const upstashUrl = process.env.UPSTASH_URL || process.env.KV_REST_API_URL || process.env.UPSTASH_REDIS_REST_URL; + const upstashToken = process.env.UPSTASH_TOKEN || process.env.KV_REST_API_TOKEN || process.env.UPSTASH_REDIS_REST_TOKEN; if (!upstashUrl || !upstashToken) { throw new Error( - 'UPSTASH_URL and UPSTASH_TOKEN (or KV_REST_API_URL and KV_REST_API_TOKEN) env variables must be set' + 'UPSTASH_URL and UPSTASH_TOKEN (or KV_REST_API/UPSTASH_REDIS_REST) env variables must be set' ); } From 6c768923ae65b29d68c14a6cde83a13e9a5d6f3a Mon Sep 17 00:00:00 2001 From: liiider Date: Mon, 9 Feb 2026 16:28:26 +0800 Subject: [PATCH 03/22] fix: add force-dynamic to API routes to fix Vercel static rendering errors --- src/app/api/admin/category/route.ts | 2 ++ src/app/api/admin/config/route.ts | 2 ++ src/app/api/favorites/route.ts | 2 ++ src/app/api/search/route.ts | 2 ++ src/app/api/search/suggestions/route.ts | 2 ++ 5 files changed, 10 insertions(+) diff --git a/src/app/api/admin/category/route.ts b/src/app/api/admin/category/route.ts index 6fc2226254..2a7094bcef 100644 --- a/src/app/api/admin/category/route.ts +++ b/src/app/api/admin/category/route.ts @@ -7,6 +7,8 @@ import { getConfig } from '@/lib/config'; import { db } from '@/lib/db'; export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + // 支持的操作类型 type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort'; diff --git a/src/app/api/admin/config/route.ts b/src/app/api/admin/config/route.ts index 7544700a6e..8fa43b0402 100644 --- a/src/app/api/admin/config/route.ts +++ b/src/app/api/admin/config/route.ts @@ -7,6 +7,8 @@ import { getAuthInfoFromCookie } from '@/lib/auth'; import { getConfig } from '@/lib/config'; export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + export async function GET(request: NextRequest) { const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; diff --git a/src/app/api/favorites/route.ts b/src/app/api/favorites/route.ts index 4e64310c2d..cb749960dc 100644 --- a/src/app/api/favorites/route.ts +++ b/src/app/api/favorites/route.ts @@ -8,6 +8,8 @@ import { db } from '@/lib/db'; import { Favorite } from '@/lib/types'; export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + /** * GET /api/favorites diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 229c62cb94..81b1930d69 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -8,6 +8,8 @@ import { searchFromApi } from '@/lib/downstream'; import { yellowWords } from '@/lib/yellow'; export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + export async function GET(request: NextRequest) { const authInfo = getAuthInfoFromCookie(request); diff --git a/src/app/api/search/suggestions/route.ts b/src/app/api/search/suggestions/route.ts index 88e97b440e..3bf04e795f 100644 --- a/src/app/api/search/suggestions/route.ts +++ b/src/app/api/search/suggestions/route.ts @@ -9,6 +9,8 @@ import { searchFromApi } from '@/lib/downstream'; import { yellowWords } from '@/lib/yellow'; export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + export async function GET(request: NextRequest) { try { From 9322673814cb4aac9d33ac83b3cd0417ec044793 Mon Sep 17 00:00:00 2001 From: liiider Date: Mon, 9 Feb 2026 16:44:45 +0800 Subject: [PATCH 04/22] chore: rename site to CraterTV --- README.md | 4 ++-- package.json | 2 +- scripts/generate-manifest.js | 2 +- src/app/layout.tsx | 4 ++-- src/lib/config.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8de098a847..28d194495f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# MoonTV +# CraterTV
MoonTV Logo
-> 🎬 **MoonTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。 +> 🎬 **CraterTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
diff --git a/package.json b/package.json index e5be48775d..286fccd2f2 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "moontv", + "name": "creatertv", "version": "0.1.0", "private": true, "scripts": { diff --git a/scripts/generate-manifest.js b/scripts/generate-manifest.js index 14b0f026b0..0285b9a759 100644 --- a/scripts/generate-manifest.js +++ b/scripts/generate-manifest.js @@ -11,7 +11,7 @@ const publicDir = path.join(projectRoot, 'public'); const manifestPath = path.join(publicDir, 'manifest.json'); // 从环境变量获取站点名称 -const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV'; +const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'CraterTV'; // manifest.json 模板 const manifestTemplate = { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e24fceab1e..e062f73ccd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -18,7 +18,7 @@ export const dynamic = 'force-dynamic'; export async function generateMetadata(): Promise { const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; const config = await getConfig(); - let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV'; + let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'CraterTV'; if (storageType !== 'localstorage') { siteName = config.SiteConfig.SiteName; } @@ -41,7 +41,7 @@ export default async function RootLayout({ }) { const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; - let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV'; + let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'CraterTV'; let announcement = process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。'; diff --git a/src/lib/config.ts b/src/lib/config.ts index d984c6392b..806bbf8411 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -201,7 +201,7 @@ async function getInitConfig(configFile: string, subConfig: { ConfigFile: configFile, ConfigSubscribtion: subConfig, SiteConfig: { - SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV', + SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'CraterTV', Announcement: process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。', From 139f8db602e6e222704fb68abc083888f651665d Mon Sep 17 00:00:00 2001 From: liiider Date: Mon, 9 Feb 2026 16:45:05 +0800 Subject: [PATCH 05/22] chore: update more SiteName references to CraterTV --- src/components/SiteProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SiteProvider.tsx b/src/components/SiteProvider.tsx index 3223a31cd6..97060794bb 100644 --- a/src/components/SiteProvider.tsx +++ b/src/components/SiteProvider.tsx @@ -4,7 +4,7 @@ import { createContext, ReactNode, useContext } from 'react'; const SiteContext = createContext<{ siteName: string; announcement?: string }>({ // 默认值 - siteName: 'MoonTV', + siteName: 'CraterTV', announcement: '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。', }); From 45e1242e7e46ae3f9cca5e0f02890803d18fd9d3 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 30 Mar 2026 19:49:34 +0800 Subject: [PATCH 06/22] fix: point version links to liiider repo --- src/app/login/page.tsx | 2 +- src/components/VersionPanel.tsx | 6 +++--- src/lib/version_check.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index a0f2e72991..cc1ca3ed5b 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -35,7 +35,7 @@ function VersionDisplay() { return (
= ({ Date: Mon, 4 May 2026 11:36:39 +0800 Subject: [PATCH 07/22] fix: enforce source permissions by user group --- src/app/admin/page.tsx | 116 +++++++++++++----------- src/app/api/admin/site/route.ts | 7 +- src/app/api/admin/user/route.ts | 27 +++++- src/app/api/search/one/route.ts | 12 +-- src/app/api/search/route.ts | 12 +-- src/app/api/search/suggestions/route.ts | 7 +- src/app/api/search/ws/route.ts | 19 +--- src/app/layout.tsx | 4 - src/lib/admin.types.ts | 1 - src/lib/config.ts | 58 ++++++------ 10 files changed, 129 insertions(+), 134 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 830fffee7d..b2a2cc4daa 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -265,7 +265,6 @@ interface SiteConfig { DoubanProxy: string; DoubanImageProxyType: string; DoubanImageProxy: string; - DisableYellowFilter: boolean; FluidSearch: boolean; EnableWebLive: boolean; } @@ -413,6 +412,41 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { // 获取用户组列表 const userGroups = config?.UserConfig?.Tags || []; + const getGroupAllowedApiKeys = useCallback((tags?: string[]) => { + if (!tags || tags.length === 0) return null; + + const allowedApiKeys = new Set(); + tags.forEach((tagName) => { + const group = userGroups.find((item) => item.name === tagName); + group?.enabledApis?.forEach((apiKey) => allowedApiKeys.add(apiKey)); + }); + return allowedApiKeys; + }, [userGroups]); + + const getSelectableSourcesForUser = useCallback((user?: { tags?: string[] } | null) => { + const sources = config?.SourceConfig || []; + const groupAllowedApiKeys = getGroupAllowedApiKeys(user?.tags); + + if (!groupAllowedApiKeys) { + return sources; + } + + return sources.filter((source) => groupAllowedApiKeys.has(source.key)); + }, [config?.SourceConfig, getGroupAllowedApiKeys]); + + const getEffectiveUserApiCount = useCallback((user: { enabledApis?: string[]; tags?: string[] }) => { + if (!user.enabledApis || user.enabledApis.length === 0) { + return null; + } + + const groupAllowedApiKeys = getGroupAllowedApiKeys(user.tags); + if (!groupAllowedApiKeys) { + return user.enabledApis.length; + } + + return user.enabledApis.filter((apiKey) => groupAllowedApiKeys.has(apiKey)).length; + }, [getGroupAllowedApiKeys]); + // 处理用户组相关操作 const handleUserGroupAction = async ( action: 'add' | 'edit' | 'delete', @@ -577,9 +611,15 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { username: string; role: 'user' | 'admin' | 'owner'; enabledApis?: string[]; + tags?: string[]; }) => { + const groupAllowedApiKeys = getGroupAllowedApiKeys(user.tags); setSelectedUser(user); - setSelectedApis(user.enabledApis || []); + setSelectedApis( + groupAllowedApiKeys + ? (user.enabledApis || []).filter((apiKey) => groupAllowedApiKeys.has(apiKey)) + : user.enabledApis || [] + ); setShowConfigureApisModal(true); }; @@ -843,7 +883,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { {group.enabledApis && group.enabledApis.length > 0 ? `${group.enabledApis.length} 个源` - : '无限制'} + : '0 个源'} @@ -953,7 +993,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { {userGroups.map((group) => ( ))} @@ -1191,9 +1231,11 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
- {user.enabledApis && user.enabledApis.length > 0 - ? `${user.enabledApis.length} 个源` - : '无限制'} + {getEffectiveUserApiCount(user) !== null + ? `${getEffectiveUserApiCount(user)} 个源` + : user.tags && user.tags.length > 0 + ? '用户组上限' + : '无限制'} {/* 配置采集源权限按钮 */} {(role === 'owner' || @@ -1324,7 +1366,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {

- 提示:全不选为无限制,选中的采集源将限制用户只能访问这些源 + 提示:用户组是采集源上限;未选择个人采集源时,不额外收窄用户组权限

@@ -1335,7 +1377,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { 选择可用的采集源:
- {config?.SourceConfig?.map((source) => ( + {getSelectableSourcesForUser(selectedUser).map((source) => (
已选择: - {selectedApis.length > 0 ? `${selectedApis.length} 个源` : '无限制'} + {selectedApis.length > 0 + ? `${selectedApis.length} 个源` + : selectedUser?.tags && selectedUser.tags.length > 0 + ? getSelectableSourcesForUser(selectedUser).length > 0 + ? '使用用户组全部源' + : '用户组未允许任何源' + : '无限制'}
@@ -1504,7 +1552,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { onClick={() => setNewUserGroup(prev => ({ ...prev, enabledApis: [] }))} className={buttonStyles.quickAction} > - 全不选(无限制) + 全不选(不允许任何源) - -

- 禁用黄色内容的过滤功能,允许显示所有内容。 -

- - {/* 流式搜索 */}
diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts index 003ddb3abe..8ce062a1d9 100644 --- a/src/app/api/admin/site/route.ts +++ b/src/app/api/admin/site/route.ts @@ -37,7 +37,6 @@ export async function POST(request: NextRequest) { DoubanProxy, DoubanImageProxyType, DoubanImageProxy, - DisableYellowFilter, FluidSearch, EnableWebLive, } = body as { @@ -49,7 +48,6 @@ export async function POST(request: NextRequest) { DoubanProxy: string; DoubanImageProxyType: string; DoubanImageProxy: string; - DisableYellowFilter: boolean; FluidSearch: boolean; EnableWebLive: boolean; }; @@ -64,8 +62,8 @@ export async function POST(request: NextRequest) { typeof DoubanProxy !== 'string' || typeof DoubanImageProxyType !== 'string' || typeof DoubanImageProxy !== 'string' || - typeof DisableYellowFilter !== 'boolean' || - typeof FluidSearch !== 'boolean' + typeof FluidSearch !== 'boolean' || + typeof EnableWebLive !== 'boolean' ) { return NextResponse.json({ error: '参数格式错误' }, { status: 400 }); } @@ -93,7 +91,6 @@ export async function POST(request: NextRequest) { DoubanProxy, DoubanImageProxyType, DoubanImageProxy, - DisableYellowFilter, FluidSearch, EnableWebLive: EnableWebLive ?? false, }; diff --git a/src/app/api/admin/user/route.ts b/src/app/api/admin/user/route.ts index 9473f25beb..ab99e6b830 100644 --- a/src/app/api/admin/user/route.ts +++ b/src/app/api/admin/user/route.ts @@ -320,9 +320,32 @@ export async function POST(request: NextRequest) { ); } + const sourceKeys = new Set(adminConfig.SourceConfig.map((s) => s.key)); + const normalizedEnabledApis = Array.isArray(enabledApis) + ? Array.from(new Set(enabledApis)).filter((apiKey) => sourceKeys.has(apiKey)) + : []; + const groupUpperBound = new Set(); + const hasGroupUpperBound = targetEntry.tags && targetEntry.tags.length > 0; + if (hasGroupUpperBound) { + targetEntry.tags.forEach((tagName: string) => { + const tagConfig = adminConfig.UserConfig.Tags?.find((t) => t.name === tagName); + tagConfig?.enabledApis?.forEach((apiKey) => groupUpperBound.add(apiKey)); + }); + } + + if (hasGroupUpperBound) { + const disallowedApis = normalizedEnabledApis.filter((apiKey) => !groupUpperBound.has(apiKey)); + if (disallowedApis.length > 0) { + return NextResponse.json( + { error: '采集源权限不能超出用户组允许的采集源范围' }, + { status: 400 } + ); + } + } + // 更新用户的采集源权限 - if (enabledApis && enabledApis.length > 0) { - targetEntry.enabledApis = enabledApis; + if (normalizedEnabledApis.length > 0) { + targetEntry.enabledApis = normalizedEnabledApis; } else { // 如果为空数组或未提供,则删除该字段,表示无限制 delete targetEntry.enabledApis; diff --git a/src/app/api/search/one/route.ts b/src/app/api/search/one/route.ts index 61a4c30be7..f441d2de23 100644 --- a/src/app/api/search/one/route.ts +++ b/src/app/api/search/one/route.ts @@ -1,9 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; -import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; +import { getAvailableApiSites, getCacheTime } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; -import { yellowWords } from '@/lib/yellow'; export const runtime = 'nodejs'; @@ -33,7 +32,6 @@ export async function GET(request: NextRequest) { ); } - const config = await getConfig(); const apiSites = await getAvailableApiSites(authInfo.username); try { @@ -50,13 +48,7 @@ export async function GET(request: NextRequest) { } const results = await searchFromApi(targetSite, query); - let result = results.filter((r) => r.title === query); - if (!config.SiteConfig.DisableYellowFilter) { - result = result.filter((result) => { - const typeName = result.type_name || ''; - return !yellowWords.some((word: string) => typeName.includes(word)); - }); - } + const result = results.filter((r) => r.title === query); const cacheTime = await getCacheTime(); if (result.length === 0) { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 81b1930d69..18733c2939 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -3,9 +3,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; -import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; +import { getAvailableApiSites, getCacheTime } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; -import { yellowWords } from '@/lib/yellow'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -35,7 +34,6 @@ export async function GET(request: NextRequest) { ); } - const config = await getConfig(); const apiSites = await getAvailableApiSites(authInfo.username); // 添加超时控制和错误处理,避免慢接口拖累整体响应 @@ -56,13 +54,7 @@ export async function GET(request: NextRequest) { const successResults = results .filter((result) => result.status === 'fulfilled') .map((result) => (result as PromiseFulfilledResult).value); - let flattenedResults = successResults.flat(); - if (!config.SiteConfig.DisableYellowFilter) { - flattenedResults = flattenedResults.filter((result) => { - const typeName = result.type_name || ''; - return !yellowWords.some((word: string) => typeName.includes(word)); - }); - } + const flattenedResults = successResults.flat(); const cacheTime = await getCacheTime(); if (flattenedResults.length === 0) { diff --git a/src/app/api/search/suggestions/route.ts b/src/app/api/search/suggestions/route.ts index 3bf04e795f..5ba3fce52c 100644 --- a/src/app/api/search/suggestions/route.ts +++ b/src/app/api/search/suggestions/route.ts @@ -2,11 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'; -import { AdminConfig } from '@/lib/admin.types'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites, getConfig } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; -import { yellowWords } from '@/lib/yellow'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -29,7 +27,7 @@ export async function GET(request: NextRequest) { } // 生成建议 - const suggestions = await generateSuggestions(config, query, authInfo.username); + const suggestions = await generateSuggestions(query, authInfo.username); // 从配置中获取缓存时间,如果没有配置则使用默认值300秒(5分钟) const cacheTime = config.SiteConfig.SiteInterfaceCacheTime || 300; @@ -51,7 +49,7 @@ export async function GET(request: NextRequest) { } } -async function generateSuggestions(config: AdminConfig, query: string, username: string): Promise< +async function generateSuggestions(query: string, username: string): Promise< Array<{ text: string; type: 'exact' | 'related' | 'suggestion'; @@ -71,7 +69,6 @@ async function generateSuggestions(config: AdminConfig, query: string, username: realKeywords = Array.from( new Set( results - .filter((r: any) => config.SiteConfig.DisableYellowFilter || !yellowWords.some((word: string) => (r.type_name || '').includes(word))) .map((r: any) => r.title) .filter(Boolean) .flatMap((title: string) => title.split(/[ -::·、-]/)) diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index 4d7c675c89..2f881991dd 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -3,9 +3,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; -import { getAvailableApiSites, getConfig } from '@/lib/config'; +import { getAvailableApiSites } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; -import { yellowWords } from '@/lib/yellow'; export const runtime = 'nodejs'; @@ -30,7 +29,6 @@ export async function GET(request: NextRequest) { ); } - const config = await getConfig(); const apiSites = await getAvailableApiSites(authInfo.username); // 共享状态 @@ -87,15 +85,6 @@ export async function GET(request: NextRequest) { const results = await searchPromise as any[]; - // 过滤黄色内容 - let filteredResults = results; - if (!config.SiteConfig.DisableYellowFilter) { - filteredResults = results.filter((result) => { - const typeName = result.type_name || ''; - return !yellowWords.some((word: string) => typeName.includes(word)); - }); - } - // 发送该源的搜索结果 completedSources++; @@ -104,7 +93,7 @@ export async function GET(request: NextRequest) { type: 'source_result', source: site.key, sourceName: site.name, - results: filteredResults, + results, timestamp: Date.now() })}\n\n`; @@ -114,8 +103,8 @@ export async function GET(request: NextRequest) { } } - if (filteredResults.length > 0) { - allResults.push(...filteredResults); + if (results.length > 0) { + allResults.push(...results); } } catch (error) { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 988e78d2ab..64d6c94ae8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -51,8 +51,6 @@ export default async function RootLayout({ let doubanImageProxyType = 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'; let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false'; let enableWebLive = false; let customCategories = [] as { @@ -69,7 +67,6 @@ export default async function RootLayout({ doubanProxy = config.SiteConfig.DoubanProxy; doubanImageProxyType = config.SiteConfig.DoubanImageProxyType; doubanImageProxy = config.SiteConfig.DoubanImageProxy; - disableYellowFilter = config.SiteConfig.DisableYellowFilter; customCategories = config.CustomCategories.filter( (category) => !category.disabled ).map((category) => ({ @@ -88,7 +85,6 @@ export default async function RootLayout({ DOUBAN_PROXY: doubanProxy, DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType, DOUBAN_IMAGE_PROXY: doubanImageProxy, - DISABLE_YELLOW_FILTER: disableYellowFilter, CUSTOM_CATEGORIES: customCategories, FLUID_SEARCH: fluidSearch, ENABLE_WEB_LIVE: enableWebLive, diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index a79857ef94..05bd8a55e6 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -14,7 +14,6 @@ export interface AdminConfig { DoubanProxy: string; DoubanImageProxyType: string; DoubanImageProxy: string; - DisableYellowFilter: boolean; FluidSearch: boolean; EnableWebLive: boolean; }; diff --git a/src/lib/config.ts b/src/lib/config.ts index ed2b3ec044..86987d1a59 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -214,8 +214,6 @@ async function getInitConfig(configFile: string, subConfig: { DoubanImageProxyType: 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', FluidSearch: process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false', EnableWebLive: false, @@ -420,53 +418,53 @@ export async function getCacheTime(): Promise { export async function getAvailableApiSites(user?: string): Promise { const config = await getConfig(); const allApiSites = config.SourceConfig.filter((s) => !s.disabled); + const toApiSites = (sites: typeof allApiSites): ApiSite[] => + sites.map((s) => ({ + key: s.key, + name: s.name, + api: s.api, + detail: s.detail, + })); if (!user) { - return allApiSites; + return toApiSites(allApiSites); } const userConfig = config.UserConfig.Users.find((u) => u.username === user); if (!userConfig) { - return allApiSites; + return []; } - // 优先根据用户自己的 enabledApis 配置查找 - if (userConfig.enabledApis && userConfig.enabledApis.length > 0) { - const userApiSitesSet = new Set(userConfig.enabledApis); - return allApiSites.filter((s) => userApiSitesSet.has(s.key)).map((s) => ({ - key: s.key, - name: s.name, - api: s.api, - detail: s.detail, - })); - } - - // 如果没有 enabledApis 配置,则根据 tags 查找 - if (userConfig.tags && userConfig.tags.length > 0 && config.UserConfig.Tags) { - const enabledApisFromTags = new Set(); + let groupApiSitesSet: Set | null = null; + if (userConfig.tags && userConfig.tags.length > 0) { + groupApiSitesSet = new Set(); - // 遍历用户的所有 tags,收集对应的 enabledApis userConfig.tags.forEach(tagName => { const tagConfig = config.UserConfig.Tags?.find(t => t.name === tagName); if (tagConfig && tagConfig.enabledApis) { - tagConfig.enabledApis.forEach(apiKey => enabledApisFromTags.add(apiKey)); + tagConfig.enabledApis.forEach(apiKey => groupApiSitesSet?.add(apiKey)); } }); + } - if (enabledApisFromTags.size > 0) { - return allApiSites.filter((s) => enabledApisFromTags.has(s.key)).map((s) => ({ - key: s.key, - name: s.name, - api: s.api, - detail: s.detail, - })); - } + if (userConfig.enabledApis) { + const userApiSitesSet = new Set(userConfig.enabledApis); + return toApiSites( + allApiSites.filter((s) => + userApiSitesSet.has(s.key) && + (!groupApiSitesSet || groupApiSitesSet.has(s.key)) + ) + ); + } + + if (groupApiSitesSet) { + return toApiSites(allApiSites.filter((s) => groupApiSitesSet.has(s.key))); } // 如果都没有配置,返回所有可用的 API 站点 - return allApiSites; + return toApiSites(allApiSites); } export async function setCachedConfig(config: AdminConfig) { cachedConfig = config; -} \ No newline at end of file +} From 087870f28e8d5c4f5b040d085f6afbe56d8d6dd3 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 4 May 2026 11:43:16 +0800 Subject: [PATCH 08/22] fix: narrow group source permissions type --- src/lib/config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/config.ts b/src/lib/config.ts index 86987d1a59..c103873845 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -458,7 +458,8 @@ export async function getAvailableApiSites(user?: string): Promise { } if (groupApiSitesSet) { - return toApiSites(allApiSites.filter((s) => groupApiSitesSet.has(s.key))); + const allowedApiKeys = groupApiSitesSet; + return toApiSites(allApiSites.filter((s) => allowedApiKeys.has(s.key))); } // 如果都没有配置,返回所有可用的 API 站点 From b49dc2dc5c15c24f747a39855119efd480d9ec80 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 4 May 2026 18:42:39 +0800 Subject: [PATCH 09/22] fix: prevent stale source permissions --- src/app/api/admin/user/route.ts | 92 +++++++++++++++++++++---- src/app/api/detail/route.ts | 14 ++-- src/app/api/search/one/route.ts | 23 ++----- src/app/api/search/resources/route.ts | 6 +- src/app/api/search/route.ts | 26 +++---- src/app/api/search/suggestions/route.ts | 18 ++--- src/app/api/search/ws/route.ts | 2 +- src/lib/config.ts | 21 ++++-- 8 files changed, 126 insertions(+), 76 deletions(-) diff --git a/src/app/api/admin/user/route.ts b/src/app/api/admin/user/route.ts index ab99e6b830..9e0498fc57 100644 --- a/src/app/api/admin/user/route.ts +++ b/src/app/api/admin/user/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; +import type { AdminConfig } from '@/lib/admin.types'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getConfig } from '@/lib/config'; import { db } from '@/lib/db'; @@ -23,6 +24,55 @@ const ACTIONS = [ 'batchUpdateUserGroups', ] as const; +type UserEntry = AdminConfig['UserConfig']['Users'][number]; + +function normalizeEnabledApis(enabledApis: unknown, sourceKeys: Set) { + return Array.isArray(enabledApis) + ? Array.from(new Set(enabledApis)).filter( + (apiKey): apiKey is string => + typeof apiKey === 'string' && sourceKeys.has(apiKey) + ) + : []; +} + +function getGroupUpperBound( + adminConfig: AdminConfig, + groupNames?: string[] +): Set | null { + if (!groupNames || groupNames.length === 0) { + return null; + } + + const groupUpperBound = new Set(); + groupNames.forEach((tagName) => { + const tagConfig = adminConfig.UserConfig.Tags?.find((t) => t.name === tagName); + tagConfig?.enabledApis?.forEach((apiKey) => groupUpperBound.add(apiKey)); + }); + + return groupUpperBound; +} + +function pruneUserApisToGroupUpperBound( + adminConfig: AdminConfig, + user: UserEntry +) { + if (user.username === process.env.USERNAME) { + delete user.enabledApis; + delete user.tags; + return; + } + + const groupUpperBound = getGroupUpperBound(adminConfig, user.tags); + if (!groupUpperBound || !user.enabledApis) { + return; + } + + user.enabledApis = user.enabledApis.filter((apiKey) => groupUpperBound.has(apiKey)); + if (user.enabledApis.length === 0) { + delete user.enabledApis; + } +} + export async function POST(request: NextRequest) { const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; if (storageType === 'localstorage') { @@ -320,20 +370,17 @@ export async function POST(request: NextRequest) { ); } + if (targetEntry.username === process.env.USERNAME) { + delete targetEntry.enabledApis; + delete targetEntry.tags; + break; + } + const sourceKeys = new Set(adminConfig.SourceConfig.map((s) => s.key)); - const normalizedEnabledApis = Array.isArray(enabledApis) - ? Array.from(new Set(enabledApis)).filter((apiKey) => sourceKeys.has(apiKey)) - : []; - const groupUpperBound = new Set(); - const hasGroupUpperBound = targetEntry.tags && targetEntry.tags.length > 0; - if (hasGroupUpperBound) { - targetEntry.tags.forEach((tagName: string) => { - const tagConfig = adminConfig.UserConfig.Tags?.find((t) => t.name === tagName); - tagConfig?.enabledApis?.forEach((apiKey) => groupUpperBound.add(apiKey)); - }); - } - - if (hasGroupUpperBound) { + const normalizedEnabledApis = normalizeEnabledApis(enabledApis, sourceKeys); + const groupUpperBound = getGroupUpperBound(adminConfig, targetEntry.tags); + + if (groupUpperBound) { const disallowedApis = normalizedEnabledApis.filter((apiKey) => !groupUpperBound.has(apiKey)); if (disallowedApis.length > 0) { return NextResponse.json( @@ -365,6 +412,9 @@ export async function POST(request: NextRequest) { adminConfig.UserConfig.Tags = []; } + const sourceKeys = new Set(adminConfig.SourceConfig.map((s) => s.key)); + const normalizedEnabledApis = normalizeEnabledApis(enabledApis, sourceKeys); + switch (groupAction) { case 'add': { // 检查用户组是否已存在 @@ -373,7 +423,7 @@ export async function POST(request: NextRequest) { } adminConfig.UserConfig.Tags.push({ name: groupName, - enabledApis: enabledApis || [], + enabledApis: normalizedEnabledApis, }); break; } @@ -382,7 +432,10 @@ export async function POST(request: NextRequest) { if (groupIndex === -1) { return NextResponse.json({ error: '用户组不存在' }, { status: 404 }); } - adminConfig.UserConfig.Tags[groupIndex].enabledApis = enabledApis || []; + adminConfig.UserConfig.Tags[groupIndex].enabledApis = normalizedEnabledApis; + adminConfig.UserConfig.Users + .filter((user) => user.tags?.includes(groupName)) + .forEach((user) => pruneUserApisToGroupUpperBound(adminConfig, user)); break; } case 'delete': { @@ -435,6 +488,12 @@ export async function POST(request: NextRequest) { } // 更新用户的用户组 + if (targetEntry.username === process.env.USERNAME) { + delete targetEntry.enabledApis; + delete targetEntry.tags; + break; + } + if (userGroups && userGroups.length > 0) { targetEntry.tags = userGroups; } else { @@ -442,6 +501,8 @@ export async function POST(request: NextRequest) { delete targetEntry.tags; } + pruneUserApisToGroupUpperBound(adminConfig, targetEntry); + break; } case 'batchUpdateUserGroups': { @@ -471,6 +532,7 @@ export async function POST(request: NextRequest) { // 如果为空数组或未提供,则删除该字段,表示无用户组 delete targetUser.tags; } + pruneUserApisToGroupUpperBound(adminConfig, targetUser); } } diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 59dba3bc70..ed1293f3ac 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -1,11 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; -import { getAvailableApiSites, getCacheTime } from '@/lib/config'; +import { getAvailableApiSites } from '@/lib/config'; import { getDetailFromApi } from '@/lib/downstream'; export const runtime = 'nodejs'; +const noStoreHeaders = { + 'Cache-Control': 'no-store', +}; + export async function GET(request: NextRequest) { const authInfo = getAuthInfoFromCookie(request); if (!authInfo || !authInfo.username) { @@ -33,15 +37,9 @@ export async function GET(request: NextRequest) { } const result = await getDetailFromApi(apiSite, id); - const cacheTime = await getCacheTime(); return NextResponse.json(result, { - headers: { - 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, - 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, - 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, - 'Netlify-Vary': 'query', - }, + headers: noStoreHeaders, }); } catch (error) { return NextResponse.json( diff --git a/src/app/api/search/one/route.ts b/src/app/api/search/one/route.ts index f441d2de23..28f8ec642a 100644 --- a/src/app/api/search/one/route.ts +++ b/src/app/api/search/one/route.ts @@ -1,11 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; -import { getAvailableApiSites, getCacheTime } from '@/lib/config'; +import { getAvailableApiSites } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; export const runtime = 'nodejs'; +const noStoreHeaders = { + 'Cache-Control': 'no-store', +}; + // OrionTV 兼容接口 export async function GET(request: NextRequest) { const authInfo = getAuthInfoFromCookie(request); @@ -18,16 +22,10 @@ export async function GET(request: NextRequest) { const resourceId = searchParams.get('resourceId'); if (!query || !resourceId) { - const cacheTime = await getCacheTime(); return NextResponse.json( { result: null, error: '缺少必要参数: q 或 resourceId' }, { - headers: { - 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, - 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, - 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, - 'Netlify-Vary': 'query', - }, + headers: noStoreHeaders, } ); } @@ -49,8 +47,6 @@ export async function GET(request: NextRequest) { const results = await searchFromApi(targetSite, query); const result = results.filter((r) => r.title === query); - const cacheTime = await getCacheTime(); - if (result.length === 0) { return NextResponse.json( { @@ -63,12 +59,7 @@ export async function GET(request: NextRequest) { return NextResponse.json( { results: result }, { - headers: { - 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, - 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, - 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, - 'Netlify-Vary': 'query', - }, + headers: noStoreHeaders, } ); } diff --git a/src/app/api/search/resources/route.ts b/src/app/api/search/resources/route.ts index 58acb9bb76..e8f9d608d4 100644 --- a/src/app/api/search/resources/route.ts +++ b/src/app/api/search/resources/route.ts @@ -7,6 +7,10 @@ import { getAvailableApiSites } from '@/lib/config'; export const runtime = 'nodejs'; +const noStoreHeaders = { + 'Cache-Control': 'no-store', +}; + // OrionTV 兼容接口 export async function GET(request: NextRequest) { const authInfo = getAuthInfoFromCookie(request); @@ -16,7 +20,7 @@ export async function GET(request: NextRequest) { try { const apiSites = await getAvailableApiSites(authInfo.username); - return NextResponse.json(apiSites); + return NextResponse.json(apiSites, { headers: noStoreHeaders }); } catch (error) { return NextResponse.json({ error: '获取资源失败' }, { status: 500 }); } diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 18733c2939..9a76fd3e08 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -3,12 +3,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; -import { getAvailableApiSites, getCacheTime } from '@/lib/config'; +import { getAvailableApiSites } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +const noStoreHeaders = { + 'Cache-Control': 'no-store', +}; export async function GET(request: NextRequest) { const authInfo = getAuthInfoFromCookie(request); @@ -20,16 +23,10 @@ export async function GET(request: NextRequest) { const query = searchParams.get('q'); if (!query) { - const cacheTime = await getCacheTime(); return NextResponse.json( { results: [] }, { - headers: { - 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, - 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, - 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, - 'Netlify-Vary': 'query', - }, + headers: noStoreHeaders, } ); } @@ -55,22 +52,19 @@ export async function GET(request: NextRequest) { .filter((result) => result.status === 'fulfilled') .map((result) => (result as PromiseFulfilledResult).value); const flattenedResults = successResults.flat(); - const cacheTime = await getCacheTime(); if (flattenedResults.length === 0) { // no cache if empty - return NextResponse.json({ results: [] }, { status: 200 }); + return NextResponse.json( + { results: [] }, + { status: 200, headers: noStoreHeaders } + ); } return NextResponse.json( { results: flattenedResults }, { - headers: { - 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, - 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, - 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, - 'Netlify-Vary': 'query', - }, + headers: noStoreHeaders, } ); } catch (error) { diff --git a/src/app/api/search/suggestions/route.ts b/src/app/api/search/suggestions/route.ts index 5ba3fce52c..7f830baeea 100644 --- a/src/app/api/search/suggestions/route.ts +++ b/src/app/api/search/suggestions/route.ts @@ -3,12 +3,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; -import { getAvailableApiSites, getConfig } from '@/lib/config'; +import { getAvailableApiSites } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +const noStoreHeaders = { + 'Cache-Control': 'no-store', +}; export async function GET(request: NextRequest) { try { @@ -18,29 +21,20 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const config = await getConfig(); const { searchParams } = new URL(request.url); const query = searchParams.get('q')?.trim(); if (!query) { - return NextResponse.json({ suggestions: [] }); + return NextResponse.json({ suggestions: [] }, { headers: noStoreHeaders }); } // 生成建议 const suggestions = await generateSuggestions(query, authInfo.username); - // 从配置中获取缓存时间,如果没有配置则使用默认值300秒(5分钟) - const cacheTime = config.SiteConfig.SiteInterfaceCacheTime || 300; - return NextResponse.json( { suggestions }, { - headers: { - 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, - 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, - 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, - 'Netlify-Vary': 'query', - }, + headers: noStoreHeaders, } ); } catch (error) { diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index 2f881991dd..b63071ddd6 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -167,7 +167,7 @@ export async function GET(request: NextRequest) { return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', + 'Cache-Control': 'no-store', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET', diff --git a/src/lib/config.ts b/src/lib/config.ts index c103873845..c4514b45ae 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -343,7 +343,6 @@ 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) => { @@ -356,8 +355,6 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { username: ownerUser!, role: 'owner', banned: false, - enabledApis: originOwnerCfg?.enabledApis || undefined, - tags: originOwnerCfg?.tags || undefined, }); // 采集源去重 @@ -430,6 +427,10 @@ export async function getAvailableApiSites(user?: string): Promise { return toApiSites(allApiSites); } + if (user === process.env.USERNAME) { + return toApiSites(allApiSites); + } + const userConfig = config.UserConfig.Users.find((u) => u.username === user); if (!userConfig) { return []; @@ -449,11 +450,17 @@ export async function getAvailableApiSites(user?: string): Promise { if (userConfig.enabledApis) { const userApiSitesSet = new Set(userConfig.enabledApis); + if (groupApiSitesSet) { + const allowedByGroup = groupApiSitesSet; + return toApiSites( + allApiSites.filter((s) => + userApiSitesSet.has(s.key) && allowedByGroup.has(s.key) + ) + ); + } + return toApiSites( - allApiSites.filter((s) => - userApiSitesSet.has(s.key) && - (!groupApiSitesSet || groupApiSitesSet.has(s.key)) - ) + allApiSites.filter((s) => userApiSitesSet.has(s.key)) ); } From 782ce914228acb35db22a1a0e75cdde2b9915c65 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 4 May 2026 18:47:25 +0800 Subject: [PATCH 10/22] fix: tighten role permission boundaries --- src/app/api/admin/source/validate/route.ts | 11 ++++++++++- src/lib/config.ts | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/app/api/admin/source/validate/route.ts b/src/app/api/admin/source/validate/route.ts index 12e63e4f0b..9353151141 100644 --- a/src/app/api/admin/source/validate/route.ts +++ b/src/app/api/admin/source/validate/route.ts @@ -30,6 +30,15 @@ export async function GET(request: NextRequest) { } const config = await getConfig(); + if (authInfo.username !== process.env.USERNAME) { + const user = config.UserConfig.Users.find( + (u) => u.username === authInfo.username + ); + if (!user || user.role !== 'admin' || user.banned) { + return NextResponse.json({ error: '权限不足' }, { status: 401 }); + } + } + const apiSites = config.SourceConfig; // 共享状态 @@ -189,7 +198,7 @@ export async function GET(request: NextRequest) { return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', + 'Cache-Control': 'no-store', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET', diff --git a/src/lib/config.ts b/src/lib/config.ts index c4514b45ae..cf7b116c42 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -436,6 +436,10 @@ export async function getAvailableApiSites(user?: string): Promise { return []; } + if (userConfig.banned) { + return []; + } + let groupApiSitesSet: Set | null = null; if (userConfig.tags && userConfig.tags.length > 0) { groupApiSitesSet = new Set(); From 75aa7e2c09486fe18a8a401db8b9b2fb14ff3430 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 4 May 2026 18:55:30 +0800 Subject: [PATCH 11/22] fix: remove anime daily broadcast category --- src/app/douban/page.tsx | 62 +------------------------------ src/components/DoubanSelector.tsx | 58 ++++++++++------------------- 2 files changed, 22 insertions(+), 98 deletions(-) diff --git a/src/app/douban/page.tsx b/src/app/douban/page.tsx index b8c355cfc7..c013d19810 100644 --- a/src/app/douban/page.tsx +++ b/src/app/douban/page.tsx @@ -6,7 +6,6 @@ import { useSearchParams } from 'next/navigation'; import { Suspense } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { GetBangumiCalendarData } from '@/lib/bangumi.client'; import { getDoubanCategories, getDoubanList, @@ -39,7 +38,6 @@ function DoubanPageClient() { primarySelection: '', secondarySelection: '', multiLevelSelection: {} as Record, - selectedWeekday: '', currentPage: 0, }); @@ -54,7 +52,7 @@ function DoubanPageClient() { const [primarySelection, setPrimarySelection] = useState(() => { if (type === 'movie') return '热门'; if (type === 'tv' || type === 'show') return '最近热门'; - if (type === 'anime') return '每日放送'; + if (type === 'anime') return '番剧'; return ''; }); const [secondarySelection, setSecondarySelection] = useState(() => { @@ -76,9 +74,6 @@ function DoubanPageClient() { sort: 'T', }); - // 星期选择器状态 - const [selectedWeekday, setSelectedWeekday] = useState(''); - // 获取自定义分类数据 useEffect(() => { const runtimeConfig = (window as any).RUNTIME_CONFIG; @@ -94,7 +89,6 @@ function DoubanPageClient() { primarySelection, secondarySelection, multiLevelSelection: multiLevelValues, - selectedWeekday, currentPage, }; }, [ @@ -102,7 +96,6 @@ function DoubanPageClient() { primarySelection, secondarySelection, multiLevelValues, - selectedWeekday, currentPage, ]); @@ -159,7 +152,7 @@ function DoubanPageClient() { setPrimarySelection('最近热门'); setSecondarySelection('show'); } else if (type === 'anime') { - setPrimarySelection('每日放送'); + setPrimarySelection('番剧'); setSecondarySelection('全部'); } else { setPrimarySelection(''); @@ -196,7 +189,6 @@ function DoubanPageClient() { primarySelection: string; secondarySelection: string; multiLevelSelection: Record; - selectedWeekday: string; currentPage: number; }, snapshot2: { @@ -204,7 +196,6 @@ function DoubanPageClient() { primarySelection: string; secondarySelection: string; multiLevelSelection: Record; - selectedWeekday: string; currentPage: number; } ) => { @@ -212,7 +203,6 @@ function DoubanPageClient() { snapshot1.type === snapshot2.type && snapshot1.primarySelection === snapshot2.primarySelection && snapshot1.secondarySelection === snapshot2.secondarySelection && - snapshot1.selectedWeekday === snapshot2.selectedWeekday && snapshot1.currentPage === snapshot2.currentPage && JSON.stringify(snapshot1.multiLevelSelection) === JSON.stringify(snapshot2.multiLevelSelection) @@ -255,7 +245,6 @@ function DoubanPageClient() { primarySelection, secondarySelection, multiLevelSelection: multiLevelValues, - selectedWeekday, currentPage: 0, }; @@ -286,31 +275,6 @@ function DoubanPageClient() { } else { throw new Error('没有找到对应的分类'); } - } else if (type === 'anime' && primarySelection === '每日放送') { - const calendarData = await GetBangumiCalendarData(); - const weekdayData = calendarData.find( - (item) => item.weekday.en === selectedWeekday - ); - if (weekdayData) { - data = { - code: 200, - message: 'success', - list: weekdayData.items.map((item) => ({ - id: item.id?.toString() || '', - title: item.name_cn || item.name, - poster: - item.images.large || - item.images.common || - item.images.medium || - item.images.small || - item.images.grid, - rate: item.rating?.score?.toFixed(1) || '', - year: item.air_date?.split('-')?.[0] || '', - })), - }; - } else { - throw new Error('没有找到对应的日期'); - } } else if (type === 'anime') { data = await getDoubanRecommends({ kind: primarySelection === '番剧' ? 'tv' : 'movie', @@ -380,7 +344,6 @@ function DoubanPageClient() { primarySelection, secondarySelection, multiLevelValues, - selectedWeekday, getRequestParams, customCategories, ]); @@ -414,7 +377,6 @@ function DoubanPageClient() { primarySelection, secondarySelection, multiLevelValues, - selectedWeekday, loadInitialData, ]); @@ -428,7 +390,6 @@ function DoubanPageClient() { primarySelection, secondarySelection, multiLevelSelection: multiLevelValues, - selectedWeekday, currentPage, }; @@ -454,13 +415,6 @@ function DoubanPageClient() { } else { throw new Error('没有找到对应的分类'); } - } else if (type === 'anime' && primarySelection === '每日放送') { - // 每日放送模式下,不进行数据请求,返回空数据 - data = { - code: 200, - message: 'success', - list: [], - }; } else if (type === 'anime') { data = await getDoubanRecommends({ kind: primarySelection === '番剧' ? 'tv' : 'movie', @@ -545,7 +499,6 @@ function DoubanPageClient() { secondarySelection, customCategories, multiLevelValues, - selectedWeekday, ]); // 设置滚动监听 @@ -678,10 +631,6 @@ function DoubanPageClient() { [multiLevelValues] ); - const handleWeekdayChange = useCallback((weekday: string) => { - setSelectedWeekday(weekday); - }, []); - const getPageTitle = () => { // 根据 type 生成标题 return type === 'movie' @@ -696,9 +645,6 @@ function DoubanPageClient() { }; const getPageDescription = () => { - if (type === 'anime' && primarySelection === '每日放送') { - return '来自 Bangumi 番组计划的精选内容'; - } return '来自豆瓣的精选内容'; }; @@ -736,7 +682,6 @@ function DoubanPageClient() { onPrimaryChange={handlePrimaryChange} onSecondaryChange={handleSecondaryChange} onMultiLevelChange={handleMultiLevelChange} - onWeekdayChange={handleWeekdayChange} />
) : ( @@ -776,9 +721,6 @@ function DoubanPageClient() { rate={item.rate} year={item.year} type={type === 'movie' ? 'movie' : ''} // 电影类型严格控制,tv 不控 - isBangumi={ - type === 'anime' && primarySelection === '每日放送' - } />
)} diff --git a/src/components/DoubanSelector.tsx b/src/components/DoubanSelector.tsx index 36c5c8455f..0484487320 100644 --- a/src/components/DoubanSelector.tsx +++ b/src/components/DoubanSelector.tsx @@ -5,7 +5,6 @@ import React, { useEffect, useRef, useState } from 'react'; import MultiLevelSelector from './MultiLevelSelector'; -import WeekdaySelector from './WeekdaySelector'; interface SelectorOption { label: string; @@ -19,7 +18,6 @@ interface DoubanSelectorProps { onPrimaryChange: (value: string) => void; onSecondaryChange: (value: string) => void; onMultiLevelChange?: (values: Record) => void; - onWeekdayChange: (weekday: string) => void; } const DoubanSelector: React.FC = ({ @@ -29,7 +27,6 @@ const DoubanSelector: React.FC = ({ onPrimaryChange, onSecondaryChange, onMultiLevelChange, - onWeekdayChange, }) => { // 为不同的选择器创建独立的refs和状态 const primaryContainerRef = useRef(null); @@ -96,7 +93,6 @@ const DoubanSelector: React.FC = ({ // 动漫一级选择器选项 const animePrimaryOptions: SelectorOption[] = [ - { label: '每日放送', value: '每日放送' }, { label: '番剧', value: '番剧' }, { label: '剧场版', value: '剧场版' }, ]; @@ -472,41 +468,27 @@ const DoubanSelector: React.FC = ({ - {/* 筛选部分 - 根据一级选择器显示不同内容 */} - {(primarySelection || animePrimaryOptions[0].value) === '每日放送' ? ( - // 每日放送分类下显示星期选择器 -
- - 星期 - -
- -
-
- ) : ( - // 其他分类下显示原有的筛选功能 -
- - 筛选 - -
- {(primarySelection || animePrimaryOptions[0].value) === - '番剧' ? ( - - ) : ( - - )} -
+
+ + 筛选 + +
+ {(primarySelection || animePrimaryOptions[0].value) === + '番剧' ? ( + + ) : ( + + )}
- )} +
)} From de3f4c3ccf271eef568b88b54c4df0062a80766c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 4 May 2026 18:59:48 +0800 Subject: [PATCH 12/22] docs: rewrite readme for current deployment --- README.md | 481 ++++++++++++++---------------------------------------- 1 file changed, 123 insertions(+), 358 deletions(-) diff --git a/README.md b/README.md index 28d194495f..bb82648a89 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # CraterTV
- MoonTV Logo + CraterTV Logo
-> 🎬 **CraterTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。 +CraterTV 是一个基于 Next.js 的影视聚合与播放管理工具。项目不内置任何播放源或直播源,部署后需要站长自行配置采集源、用户和站点信息。 + +本分支重点收紧了用户权限模型,移除了黄色过滤器开关,并简化了动漫分类:动漫页保留 `番剧` 和 `剧场版`,默认进入 `番剧`,不再提供 Bangumi 每日放送分类。
@@ -12,292 +14,117 @@ ![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3-38bdf8?logo=tailwindcss) ![TypeScript](https://img.shields.io/badge/TypeScript-4.x-3178c6?logo=typescript) ![License](https://img.shields.io/badge/License-MIT-green) -![Docker Ready](https://img.shields.io/badge/Docker-ready-blue?logo=docker)
---- - -## ✨ 功能特性 - -- 🔍 **多源聚合搜索**:一次搜索立刻返回全源结果。 -- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。 -- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。 -- ❤️ **收藏 + 继续观看**:支持 Kvrocks/Redis/Upstash 存储,多端同步进度。 -- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。 -- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。 -- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)。 - -### 注意:部署后项目为空壳项目,无内置播放源和直播源,需要自行收集 - -
- 点击查看项目截图 - 项目截图 - 项目截图 - 项目截图 -
- -### 请不要在 B站、小红书、微信公众号、抖音、今日头条或其他中国大陆社交平台发布视频或文章宣传本项目,不授权任何“科技周刊/月刊”类项目或站点收录本项目。 - -## 🗺 目录 - -- [技术栈](#技术栈) -- [部署](#部署) - - [一键部署](#zeabur-一键部署) - - [Docker 部署](#Kvrocks-存储推荐) -- [配置文件](#配置文件) -- [订阅](#订阅) -- [自动更新](#自动更新) -- [环境变量](#环境变量) -- [客户端](#客户端) -- [AndroidTV 使用](#AndroidTV-使用) -- [Roadmap](#roadmap) -- [安全与隐私提醒](#安全与隐私提醒) -- [License](#license) -- [致谢](#致谢) - -## 技术栈 - -| 分类 | 主要依赖 | -| --------- | ----------------------------------------------------------------------------------------------------- | -| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router | -| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) | -| 语言 | TypeScript 4 | -| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) | -| 代码质量 | ESLint · Prettier · Jest | -| 部署 | Docker | - -## 部署 - -本项目**仅支持 Docker 或其他基于 Docker 的平台** 部署。 - -### Vercel 一键部署 (推荐) - -点击下方按钮即可一键部署到 Vercel: - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fliiider%2FLunaTV&env=USERNAME,PASSWORD,NEXT_PUBLIC_SITE_NAME,NEXT_PUBLIC_STORAGE_TYPE,UPSTASH_URL,UPSTASH_TOKEN&envDescription=%E8%AF%B7%E5%8A%A1%E5%BF%85%E5%A1%AB%E5%86%99%E6%89%80%E6%9C%89%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%EF%BC%8C%E5%90%A6%E5%88%99%E6%97%A0%E6%B3%95%E6%AD%A3%E5%B8%B8%E8%BF%90%E8%A1%8C%E3%80%82%E5%AD%98%E5%82%A8%E7%B1%BB%E5%9E%8B%E8%AF%B7%E5%A1%AB%20upstash&project-name=moontv&repository-name=moontv) - -**Vercel 部署注意**: -- 由于 Vercel 是 Serverless 环境,推荐使用 **Upstash Redis** 作为数据存储以获得完整体验。 -- **方案 A (推荐):使用 Vercel KV (Redis)** - 1. 在 Vercel 项目设置页,点击 **Storage** -> **Create Database** -> **KV** (选择 Upstash Redis)。 - 2. 创建成功后,Vercel 会自动注入环境变量 (`KV_REST_API_URL` 等)。 - 3. 项目会自动识别并使用该数据库,无需额外配置。 - 4. **优势**:完整功能(多端同步、后台管理配置可保存),免费额度足够个人使用。 - -- **方案 B (Lite 模式 - 仅本地)**: - - 环境变量 `NEXT_PUBLIC_STORAGE_TYPE` 填 `localstorage`。 - - **无需** Upstash Redis。 - - **限制**:数据仅保存在当前浏览器,无法多端同步;管理后台修改的配置无法持久保存(重启失效);直播源无法自动更新。 - -### zeabur 一键部署 - -点击下方按钮即可一键部署,自动配置 LunaTV + Kvrocks 数据库: - -[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/8MPTQU/deploy) - -**优势**: -- ✅ 无需配置,一键启动(自动部署完整环境) -- ✅ 自动 HTTPS 和全球 CDN 加速 -- ✅ 持久化存储,数据永不丢失 -- ✅ 免费额度足够个人使用 - -**⚠️ 重要提示**:部署完成后,需要在 Zeabur 中为 LunaTV 服务设置访问域名(Domain)才能在浏览器中访问。详见下方 [设置访问域名](#5-设置访问域名必须) 步骤。 - -### Kvrocks 存储(推荐) - -```yml -services: - moontv-core: - image: ghcr.io/moontechlab/lunatv:latest - container_name: moontv-core - restart: on-failure - ports: - - '3000:3000' - environment: - - USERNAME=admin - - PASSWORD=admin_password - - NEXT_PUBLIC_STORAGE_TYPE=kvrocks - - KVROCKS_URL=redis://moontv-kvrocks:6666 - networks: - - moontv-network - depends_on: - - moontv-kvrocks - moontv-kvrocks: - image: apache/kvrocks - container_name: moontv-kvrocks - restart: unless-stopped - volumes: - - kvrocks-data:/var/lib/kvrocks - networks: - - moontv-network -networks: - moontv-network: - driver: bridge -volumes: - kvrocks-data: -``` - -### Redis 存储(有一定的丢数据风险) - -```yml -services: - moontv-core: - image: ghcr.io/moontechlab/lunatv:latest - container_name: moontv-core - restart: on-failure - ports: - - '3000:3000' - environment: - - USERNAME=admin - - PASSWORD=admin_password - - NEXT_PUBLIC_STORAGE_TYPE=redis - - REDIS_URL=redis://moontv-redis:6379 - networks: - - moontv-network - depends_on: - - moontv-redis - moontv-redis: - image: redis:alpine - container_name: moontv-redis - restart: unless-stopped - networks: - - moontv-network - # 请开启持久化,否则升级/重启后数据丢失 - volumes: - - ./data:/data -networks: - moontv-network: - driver: bridge -``` - -### Upstash 存储 - -1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。 -2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN** -3. 使用如下 docker compose -```yml -services: - moontv-core: - image: ghcr.io/moontechlab/lunatv:latest - container_name: moontv-core - restart: on-failure - ports: - - '3000:3000' - environment: - - USERNAME=admin - - PASSWORD=admin_password - - NEXT_PUBLIC_STORAGE_TYPE=upstash - - UPSTASH_URL=上面 https 开头的 HTTPS ENDPOINT - - UPSTASH_TOKEN=上面的 TOKEN -``` - -### ☁️ Zeabur 部署(推荐) - -Thanks to @SzeMeng76 +## 功能 -Zeabur 是一站式云端部署平台,使用预构建的 Docker 镜像可以快速部署,无需等待构建。 +- 多采集源聚合搜索,搜索结果按当前用户可用源过滤。 +- 在线播放、详情页、收藏、播放记录和搜索历史。 +- 后台管理站点配置、采集源、直播源、自定义分类、用户和用户组。 +- 三层权限:站长、管理员、用户。 +- 用户组作为采集源权限上限,用户个人采集源只能在用户组允许范围内选择。 +- 豆瓣电影、电视剧、综艺、动漫推荐;动漫仅保留番剧和剧场版。 +- PWA 支持,移动端和桌面端自适应。 -**部署步骤:** +## 权限模型 -1. **添加 KVRocks 服务**(先添加数据库) - - 点击 "Add Service" > "Docker Images" - - 输入镜像名称:`apache/kvrocks` - - 配置端口:`6666` (TCP) - - **记住服务名称**(通常是 `apachekvrocks`) - - **配置持久化卷(重要)**: - * 在服务设置中找到 "Volumes" 部分 - * 点击 "Add Volume" 添加新卷 - * Volume ID: `kvrocks-data`(可自定义,仅支持字母、数字、连字符) - * Path: `/var/lib/kvrocks/db` - * 保存配置 +### 站长 - > 💡 **重要提示**:持久化卷路径必须设置为 `/var/lib/kvrocks/db`(KVRocks 数据目录),这样配置文件保留在容器内,数据库文件持久化,重启后数据不会丢失! +站长由环境变量 `USERNAME` 和 `PASSWORD` 指定,拥有最高权限: -2. **添加 LunaTV 服务** - - 点击 "Add Service" > "Docker Images" - - 输入镜像名称:`ghcr.io/moontechlab/lunatv:latest` - - 配置端口:`3000` (HTTP) +- 可管理站点配置、配置文件、订阅、数据导入导出、重置、用户、管理员、采集源、直播源和分类。 +- 不受用户组和个人采集源权限限制。 +- 站长账号不会保存用户组或个人采集源限制。 -3. **配置环境变量** +### 管理员 - 在 LunaTV 服务的环境变量中添加: +管理员由站长在后台授予: - ```env - # 必填:管理员账号 - USERNAME=admin - PASSWORD=your_secure_password +- 可进入后台管理。 +- 可管理普通用户、普通用户的采集源权限和用户组。 +- 可管理站点配置、采集源、分类和直播源。 +- 不能提升或取消管理员,不能操作其他管理员,不能修改站长。 - # 必填:存储配置 - NEXT_PUBLIC_STORAGE_TYPE=kvrocks - KVROCKS_URL=redis://apachekvrocks:6666 +### 用户 - # 可选:站点配置 - SITE_BASE=https://your-domain.zeabur.app - NEXT_PUBLIC_SITE_NAME=LunaTV Enhanced - ANNOUNCEMENT=欢迎使用 LunaTV Enhanced Edition +普通用户仅用于观看和个人数据同步: - # 可选:豆瓣代理配置(推荐) - NEXT_PUBLIC_DOUBAN_PROXY_TYPE=cmliussss-cdn-tencent - NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE=cmliussss-cdn-tencent - ``` +- 不能访问后台管理接口。 +- 搜索、详情、资源列表只在自己的可用采集源内生效。 +- 如果用户属于用户组,用户组的采集源是上限。 +- 如果同时配置了用户组和个人采集源,最终可用源为二者交集。 +- 被封禁用户不能继续通过旧登录态获得采集源权限。 - **注意**: - - 使用服务名称作为主机名:`redis://apachekvrocks:6666` - - 如果服务名称不同,请替换为实际名称 - - 两个服务必须在同一个 Project 中 - -4. **部署完成** - - Zeabur 会自动拉取镜像并启动服务 - - 等待服务就绪后,需要手动设置访问域名(见下一步) - -#### 5. 设置访问域名(必须) +## 部署 - - 在 LunaTV 服务页面,点击 "Networking" 或 "网络" 标签 - - 点击 "Generate Domain" 生成 Zeabur 提供的免费域名(如 `xxx.zeabur.app`) - - 或者绑定自定义域名: - * 点击 "Add Domain" 添加你的域名 - * 按照提示配置 DNS CNAME 记录指向 Zeabur 提供的目标地址 - - 设置完域名后即可通过域名访问 LunaTV +推荐使用 Vercel 部署,也可以使用普通 Node.js 环境运行。 -6. **绑定自定义域名(可选)** - - 在服务设置中点击 "Domains" - - 添加你的自定义域名 - - 配置 DNS CNAME 记录指向 Zeabur 提供的域名 +### Vercel -#### 🔄 更新 Docker 镜像 +1. Fork 本仓库。 +2. 在 Vercel 中导入仓库。 +3. 配置环境变量。 +4. 部署完成后,用 `USERNAME` 和 `PASSWORD` 登录站长后台。 +5. 在后台导入或填写采集源配置。 -当 Docker 镜像有新版本发布时,Zeabur 不会自动更新。需要手动触发更新。 +推荐在 Vercel 上使用 Upstash Redis 存储,以保留后台配置、用户、收藏和播放记录。 -**更新步骤:** +### Node.js -1. **进入服务页面** - - 点击需要更新的服务(LunaTV 或 KVRocks) +本地或自有服务器需要 Node.js 与 pnpm: -2. **重启服务** - - 点击 **"服务状态"** 页面,再点击 **"重启当前版本"** 按钮 - - Zeabur 会自动拉取最新的 `latest` 镜像并重新部署 +```bash +corepack enable +corepack pnpm install --frozen-lockfile +corepack pnpm run build +corepack pnpm start +``` -> 💡 **提示**: -> - 使用 `latest` 标签时,Restart 会自动拉取最新镜像 -> - 生产环境推荐使用固定版本标签(如 `v5.5.6`)避免意外更新 +开发模式: -## 配置文件 +```bash +corepack pnpm run dev +``` -完成部署后为空壳应用,无播放源,需要站长在管理后台的配置文件设置中填写配置文件(后续会支持订阅) +## 环境变量 -配置文件示例如下: +| 变量 | 必填 | 说明 | +| --- | --- | --- | +| `USERNAME` | 是 | 站长用户名 | +| `PASSWORD` | 是 | 站长密码,也是登录签名密钥 | +| `NEXT_PUBLIC_STORAGE_TYPE` | 建议 | 存储方式:`localstorage`、`upstash`、`redis`、`kvrocks` | +| `UPSTASH_URL` | Upstash 时必填 | Upstash Redis REST URL,也兼容 Vercel KV 注入的 `KV_REST_API_URL` | +| `UPSTASH_TOKEN` | Upstash 时必填 | Upstash Redis REST Token,也兼容 Vercel KV 注入的 `KV_REST_API_TOKEN` | +| `REDIS_URL` | Redis 时必填 | Redis 连接地址 | +| `KVROCKS_URL` | Kvrocks 时必填 | Kvrocks 连接地址 | +| `NEXT_PUBLIC_SITE_NAME` | 否 | 站点名称,默认 `CraterTV` | +| `ANNOUNCEMENT` | 否 | 站点公告 | +| `SITE_BASE` | 否 | 站点外部访问地址,用于部分播放地址重写 | +| `NEXT_PUBLIC_SEARCH_MAX_PAGE` | 否 | 搜索接口最大拉取页数,默认 `5` | +| `NEXT_PUBLIC_DOUBAN_PROXY_TYPE` | 否 | 豆瓣数据代理类型,默认 `cmliussss-cdn-tencent` | +| `NEXT_PUBLIC_DOUBAN_PROXY` | 否 | 自定义豆瓣数据代理 URL | +| `NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE` | 否 | 豆瓣图片代理类型,默认 `cmliussss-cdn-tencent` | +| `NEXT_PUBLIC_DOUBAN_IMAGE_PROXY` | 否 | 自定义豆瓣图片代理 URL | +| `NEXT_PUBLIC_FLUID_SEARCH` | 否 | 是否启用流式搜索,默认启用;设为 `false` 可关闭 | + +`localstorage` 适合临时体验,不适合正式使用。该模式下用户数据和部分配置只保存在浏览器本地,无法多端同步,后台配置也不能可靠持久化。 + +## 采集源配置 + +项目默认不提供任何影视采集源。站长需要在后台配置文件中导入采集源,或在后台手动添加。 + +配置示例: ```json { "cache_time": 7200, "api_site": { - "dyttzy": { - "api": "http://xxx.com/api.php/provide/vod", + "demo": { + "api": "https://example.com/api.php/provide/vod", "name": "示例资源", - "detail": "http://xxx.com" + "detail": "https://example.com" } - // ...更多站点 }, "custom_category": [ { @@ -305,123 +132,61 @@ Zeabur 是一站式云端部署平台,使用预构建的 Docker 镜像可以 "type": "movie", "query": "华语" } - ] + ], + "live": { + "demo-live": { + "name": "示例直播", + "url": "https://example.com/live.m3u" + } + } } ``` -- `cache_time`:接口缓存时间(秒)。 -- `api_site`:你可以增删或替换任何资源站,字段说明: - - `key`:唯一标识,保持小写字母/数字。 - - `api`:资源站提供的 `vod` JSON API 根地址。 - - `name`:在人机界面中展示的名称。 - - `detail`:(可选)部分无法通过 API 获取剧集详情的站点,需要提供网页详情根 URL,用于爬取。 -- `custom_category`:自定义分类配置,用于在导航中添加个性化的影视分类。以 type + query 作为唯一标识。支持以下字段: - - `name`:分类显示名称(可选,如不提供则使用 query 作为显示名) - - `type`:分类类型,支持 `movie`(电影)或 `tv`(电视剧) - - `query`:搜索关键词,用于在豆瓣 API 中搜索相关内容 - -custom_category 支持的自定义分类已知如下: +说明: -- movie:热门、最新、经典、豆瓣高分、冷门佳片、华语、欧美、韩国、日本、动作、喜剧、爱情、科幻、悬疑、恐怖、治愈 -- tv:热门、美剧、英剧、韩剧、日剧、国产剧、港剧、日本动画、综艺、纪录片 +- `cache_time`:接口缓存时间,单位秒。 +- `api_site`:影视采集源,要求兼容常见 CMS V10 JSON API。 +- `api_site.*.api`:采集源 API 地址。 +- `api_site.*.name`:前端展示名称。 +- `api_site.*.detail`:可选,部分源需要网页详情地址辅助解析。 +- `custom_category`:自定义豆瓣分类。 +- `live`:直播源配置。 -也可输入如 "哈利波特" 效果等同于豆瓣搜索 +配置订阅支持将完整配置文件进行 base58 编码后,通过 HTTP URL 提供给后台拉取。 -MoonTV 支持标准的苹果 CMS V10 API 格式。 +## 使用建议 -## 订阅 +1. 部署后先设置强密码。 +2. 不要公开分享自己的实例地址。 +3. 不要将第三方采集源配置提交到公开仓库。 +4. 使用用户组作为权限上限,再给用户分配个人采集源。 +5. 修改用户组采集源后,用户个人采集源会被裁剪到用户组允许范围内。 -将完整的配置文件 base58 编码后提供 http 服务即为订阅链接,可在 MoonTV 后台/Helios 中使用。 +## 安全与声明 -## 自动更新 +- 本项目仅作为学习和个人使用工具。 +- 本项目不提供、不存储、不分发任何影视资源。 +- 所有采集源、直播源和播放内容均由部署者自行配置并承担责任。 +- 请遵守所在地法律法规,不要将实例用于公开服务、商业用途或侵权用途。 +- 请不要在公开社交平台传播项目实例、采集源或订阅链接。 -可借助 [watchtower](https://github.com/containrrr/watchtower) 自动更新镜像容器 +## Android TV -dockge/komodo 等 docker compose UI 也有自动更新功能 - -## 环境变量 +项目保留 OrionTV 兼容接口,可作为 Android TV 客户端的后端使用。可用资源同样受当前登录用户的采集源权限限制。 -| 变量 | 说明 | 可选值 | 默认值 | -| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 | -| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 | -| SITE_BASE | 站点 url | 形如 https://example.com | 空 | -| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV | -| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 | -| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 | -| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 | -| REDIS_URL | redis 连接 url | 连接 url | 空 | -| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 | -| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 | -| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 | -| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct | -| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) | -| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct | -| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) | -| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false | -| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true | - -NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释: - -- direct: 由服务器直接请求豆瓣源站 -- cors-proxy-zwei: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 由 [Zwei](https://github.com/bestzwei) 搭建 -- cmliussss-cdn-tencent: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速 -- cmliussss-cdn-ali: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速 -- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_PROXY 定义 - -NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释: - -- direct:由浏览器直接请求豆瓣分配的默认图片域名 -- server:由服务器代理请求豆瓣分配的默认图片域名 -- img3:由浏览器请求豆瓣官方的精品 cdn(阿里云) -- cmliussss-cdn-tencent:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速 -- cmliussss-cdn-ali:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速 -- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_IMAGE_PROXY 定义 - -## 客户端 - -v100.0.0 以上版本可配合 [Selene](https://github.com/MoonTechLab/Selene) 使用,移动端体验更加友好,数据完全同步 - -## AndroidTV 使用 - -目前该项目可以配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用,可以直接作为 OrionTV 后端 - -已实现播放记录和网页端同步 - -## 安全与隐私提醒 - -### 请设置密码保护并关闭公网注册 - -为了您的安全和避免潜在的法律风险,我们要求在部署时**强烈建议关闭公网注册**: - -### 部署要求 - -1. **设置环境变量 `PASSWORD`**:为您的实例设置一个强密码 -2. **仅供个人使用**:请勿将您的实例链接公开分享或传播 -3. **遵守当地法律**:请确保您的使用行为符合当地法律法规 +## 致谢 -### 重要声明 +感谢前序项目和开源作者的工作,本项目是在这些基础上继续调整和维护: -- 本项目仅供学习和个人使用 -- 请勿将部署的实例用于商业用途或公开服务 -- 如因公开分享导致的任何法律问题,用户需自行承担责任 -- 项目开发者不对用户的使用行为承担任何法律责任 -- 本项目不在中国大陆地区提供服务。如有该项目在向中国大陆地区提供服务,属个人行为。在该地区使用所产生的法律风险及责任,属于用户个人行为,与本项目无关,须自行承担全部责任。特此声明 +- [MoonTV](https://github.com/MoonTechLab/LunaTV) 与原项目贡献者。 +- [LibreTV](https://github.com/LibreSpark/LibreTV) 的启发。 +- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) 提供的初始工程思路。 +- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) 提供网页播放器能力。 +- [HLS.js](https://github.com/video-dev/hls.js/) 提供 HLS 播放支持。 +- [Zwei](https://github.com/bestzwei) 提供豆瓣 cors proxy。 +- [CMLiussss](https://github.com/cmliu) 提供豆瓣 CDN 服务。 +- 感谢所有为相关生态提供工具、文档和问题反馈的开发者。 ## License -[MIT](LICENSE) © 2025 MoonTV & Contributors - -## 致谢 - -- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) — 项目最初基于该脚手架。 -- [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上。 -- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 提供强大的网页视频播放器。 -- [HLS.js](https://github.com/video-dev/hls.js) — 实现 HLS 流媒体在浏览器中的播放支持。 -- [Zwei](https://github.com/bestzwei) — 提供获取豆瓣数据的 cors proxy -- [CMLiussss](https://github.com/cmliu) — 提供豆瓣 CDN 服务 -- 感谢所有提供免费影视接口的站点。 - -## Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=MoonTechLab/LunaTV&type=Date)](https://www.star-history.com/#MoonTechLab/LunaTV&Date) +[MIT](LICENSE) From 3ecbbff64d24f4a6d5c1369ab6ec0197a6007102 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 4 May 2026 19:17:11 +0800 Subject: [PATCH 13/22] fix: restrict search to canonical titles --- src/app/api/search/route.ts | 30 +----- src/app/api/search/ws/route.ts | 176 +++++++++++++++++---------------- src/lib/safe-search.ts | 98 ++++++++++++++++++ 3 files changed, 195 insertions(+), 109 deletions(-) create mode 100644 src/lib/safe-search.ts diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 9a76fd3e08..aea1ebe4d0 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,10 +1,8 @@ -/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ - import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites } from '@/lib/config'; -import { searchFromApi } from '@/lib/downstream'; +import { safeSearchFromApiSites } from '@/lib/safe-search'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -20,7 +18,7 @@ export async function GET(request: NextRequest) { } const { searchParams } = new URL(request.url); - const query = searchParams.get('q'); + const query = searchParams.get('q')?.trim(); if (!query) { return NextResponse.json( @@ -33,28 +31,10 @@ export async function GET(request: NextRequest) { const apiSites = await getAvailableApiSites(authInfo.username); - // 添加超时控制和错误处理,避免慢接口拖累整体响应 - const searchPromises = apiSites.map((site) => - Promise.race([ - searchFromApi(site, query), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`${site.name} timeout`)), 20000) - ), - ]).catch((err) => { - console.warn(`搜索失败 ${site.name}:`, err.message); - return []; // 返回空数组而不是抛出错误 - }) - ); - try { - const results = await Promise.allSettled(searchPromises); - const successResults = results - .filter((result) => result.status === 'fulfilled') - .map((result) => (result as PromiseFulfilledResult).value); - const flattenedResults = successResults.flat(); + const results = await safeSearchFromApiSites(apiSites, query); - if (flattenedResults.length === 0) { - // no cache if empty + if (results.length === 0) { return NextResponse.json( { results: [] }, { status: 200, headers: noStoreHeaders } @@ -62,7 +42,7 @@ export async function GET(request: NextRequest) { } return NextResponse.json( - { results: flattenedResults }, + { results }, { headers: noStoreHeaders, } diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index b63071ddd6..a9cdd20bce 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -1,10 +1,13 @@ -/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ +/* eslint-disable no-console */ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites } from '@/lib/config'; -import { searchFromApi } from '@/lib/downstream'; +import { + getCanonicalSearchTitles, + searchExactTitlesFromSite, +} from '@/lib/safe-search'; export const runtime = 'nodejs'; @@ -15,160 +18,165 @@ export async function GET(request: NextRequest) { } const { searchParams } = new URL(request.url); - const query = searchParams.get('q'); + const query = searchParams.get('q')?.trim(); if (!query) { - return new Response( - JSON.stringify({ error: '搜索关键词不能为空' }), - { - status: 400, - headers: { - 'Content-Type': 'application/json', - }, - } - ); + return new Response(JSON.stringify({ error: '搜索关键词不能为空' }), { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + }); } const apiSites = await getAvailableApiSites(authInfo.username); - - // 共享状态 let streamClosed = false; - // 创建可读流 const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); - // 辅助函数:安全地向控制器写入数据 const safeEnqueue = (data: Uint8Array) => { try { - if (streamClosed || (!controller.desiredSize && controller.desiredSize !== 0)) { - // 流已标记为关闭或控制器已关闭 + if ( + streamClosed || + (!controller.desiredSize && controller.desiredSize !== 0) + ) { return false; } controller.enqueue(data); return true; } catch (error) { - // 控制器已关闭或出现其他错误 console.warn('Failed to enqueue data:', error); streamClosed = true; return false; } }; - // 发送开始事件 - const startEvent = `data: ${JSON.stringify({ - type: 'start', - query, - totalSources: apiSites.length, - timestamp: Date.now() - })}\n\n`; + const sendEvent = (payload: Record) => + safeEnqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)); + + if ( + !sendEvent({ + type: 'start', + query, + totalSources: apiSites.length, + timestamp: Date.now(), + }) + ) { + return; + } + + if (apiSites.length === 0) { + sendEvent({ + type: 'complete', + totalResults: 0, + completedSources: 0, + timestamp: Date.now(), + }); + controller.close(); + return; + } + + let canonicalTitles: string[] = []; + try { + canonicalTitles = await getCanonicalSearchTitles(apiSites[0], query); + } catch (error) { + console.warn(`Canonical search failed ${apiSites[0].name}:`, error); + } - if (!safeEnqueue(encoder.encode(startEvent))) { - return; // 连接已关闭,提前退出 + if (canonicalTitles.length === 0) { + sendEvent({ + type: 'complete', + totalResults: 0, + completedSources: apiSites.length, + timestamp: Date.now(), + }); + controller.close(); + return; } - // 记录已完成的源数量 let completedSources = 0; - const allResults: any[] = []; + const allResults: unknown[] = []; - // 为每个源创建搜索 Promise const searchPromises = apiSites.map(async (site) => { try { - // 添加超时控制 - const searchPromise = Promise.race([ - searchFromApi(site, query), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`${site.name} timeout`)), 20000) - ), - ]); - - const results = await searchPromise as any[]; - - // 发送该源的搜索结果 + const results = await searchExactTitlesFromSite( + site, + canonicalTitles + ); completedSources++; if (!streamClosed) { - const sourceEvent = `data: ${JSON.stringify({ - type: 'source_result', - source: site.key, - sourceName: site.name, - results, - timestamp: Date.now() - })}\n\n`; - - if (!safeEnqueue(encoder.encode(sourceEvent))) { + if ( + !sendEvent({ + type: 'source_result', + source: site.key, + sourceName: site.name, + results, + timestamp: Date.now(), + }) + ) { streamClosed = true; - return; // 连接已关闭,停止处理 + return; } } if (results.length > 0) { allResults.push(...results); } - } catch (error) { console.warn(`搜索失败 ${site.name}:`, error); - - // 发送源错误事件 completedSources++; if (!streamClosed) { - const errorEvent = `data: ${JSON.stringify({ - type: 'source_error', - source: site.key, - sourceName: site.name, - error: error instanceof Error ? error.message : '搜索失败', - timestamp: Date.now() - })}\n\n`; - - if (!safeEnqueue(encoder.encode(errorEvent))) { + if ( + !sendEvent({ + type: 'source_error', + source: site.key, + sourceName: site.name, + error: error instanceof Error ? error.message : '搜索失败', + timestamp: Date.now(), + }) + ) { streamClosed = true; - return; // 连接已关闭,停止处理 + return; } } } - // 检查是否所有源都已完成 - if (completedSources === apiSites.length) { - if (!streamClosed) { - // 发送最终完成事件 - const completeEvent = `data: ${JSON.stringify({ + if (completedSources === apiSites.length && !streamClosed) { + if ( + sendEvent({ type: 'complete', totalResults: allResults.length, completedSources, - timestamp: Date.now() - })}\n\n`; - - if (safeEnqueue(encoder.encode(completeEvent))) { - // 只有在成功发送完成事件后才关闭流 - try { - controller.close(); - } catch (error) { - console.warn('Failed to close controller:', error); - } + timestamp: Date.now(), + }) + ) { + try { + controller.close(); + } catch (error) { + console.warn('Failed to close controller:', error); } } } }); - // 等待所有搜索完成 await Promise.allSettled(searchPromises); }, cancel() { - // 客户端断开连接时,标记流已关闭 streamClosed = true; console.log('Client disconnected, cancelling search stream'); }, }); - // 返回流式响应 return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', - 'Connection': 'keep-alive', + Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Allow-Headers': 'Content-Type', diff --git a/src/lib/safe-search.ts b/src/lib/safe-search.ts new file mode 100644 index 0000000000..f041618ebb --- /dev/null +++ b/src/lib/safe-search.ts @@ -0,0 +1,98 @@ +import { ApiSite } from '@/lib/config'; +import { searchFromApi } from '@/lib/downstream'; +import { SearchResult } from '@/lib/types'; + +const MAX_CANONICAL_TITLES = 8; +const SEARCH_TIMEOUT_MS = 20000; + +export function normalizeSearchTitle(title: string) { + return title + .toLowerCase() + .replace(/\s+/g, '') + .replace(/[《》「」『』【】()[\]()_::,,.。!!??-]/g, ''); +} + +function uniqueTitles(results: SearchResult[]) { + const seen = new Set(); + const titles: string[] = []; + + results.forEach((result) => { + const title = result.title?.trim(); + if (!title) return; + + const normalized = normalizeSearchTitle(title); + if (!normalized || seen.has(normalized)) return; + + seen.add(normalized); + titles.push(title); + }); + + return titles.slice(0, MAX_CANONICAL_TITLES); +} + +function dedupeResults(results: SearchResult[]) { + const seen = new Set(); + return results.filter((result) => { + const key = `${result.source}:${result.id}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +async function searchWithTimeout(site: ApiSite, query: string) { + return Promise.race([ + searchFromApi(site, query), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`${site.name} timeout`)), + SEARCH_TIMEOUT_MS + ) + ), + ]); +} + +export async function getCanonicalSearchTitles( + safeSite: ApiSite, + query: string +) { + const preSearchResults = await searchWithTimeout(safeSite, query); + return uniqueTitles(preSearchResults); +} + +export async function searchExactTitlesFromSite( + site: ApiSite, + titles: string[] +) { + const results = await Promise.all( + titles.map(async (title) => { + const normalizedTitle = normalizeSearchTitle(title); + const siteResults = await searchWithTimeout(site, title); + return siteResults.filter( + (result) => normalizeSearchTitle(result.title || '') === normalizedTitle + ); + }) + ); + + return dedupeResults(results.flat()); +} + +export async function safeSearchFromApiSites( + apiSites: ApiSite[], + query: string +) { + if (apiSites.length === 0) { + return []; + } + + const canonicalTitles = await getCanonicalSearchTitles(apiSites[0], query); + if (canonicalTitles.length === 0) { + return []; + } + + const results = await Promise.all( + apiSites.map((site) => searchExactTitlesFromSite(site, canonicalTitles)) + ); + + return dedupeResults(results.flat()); +} From cb470c59aa20d8267c9b6898e7326824ebafaa87 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 4 May 2026 19:33:00 +0800 Subject: [PATCH 14/22] feat: configure group safe presearch source --- src/app/admin/page.tsx | 3222 ++++++++++++++++++----------- src/app/api/admin/source/route.ts | 73 +- src/app/api/admin/user/route.ts | 163 +- src/app/api/search/route.ts | 13 +- src/app/api/search/ws/route.ts | 54 +- src/lib/admin.types.ts | 3 +- src/lib/config.ts | 122 +- src/lib/safe-search.ts | 21 +- 8 files changed, 2391 insertions(+), 1280 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b2a2cc4daa..c0e0dfc0c9 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -50,35 +50,53 @@ import PageLayout from '@/components/PageLayout'; // 统一按钮样式系统 const buttonStyles = { // 主要操作按钮(蓝色)- 用于配置、设置、确认等 - primary: 'px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors', + primary: + 'px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors', // 成功操作按钮(绿色)- 用于添加、启用、保存等 - success: 'px-3 py-1.5 text-sm font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-lg transition-colors', + success: + 'px-3 py-1.5 text-sm font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-lg transition-colors', // 危险操作按钮(红色)- 用于删除、禁用、重置等 - danger: 'px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-lg transition-colors', + danger: + 'px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-lg transition-colors', // 次要操作按钮(灰色)- 用于取消、关闭等 - secondary: 'px-3 py-1.5 text-sm font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg transition-colors', + secondary: + 'px-3 py-1.5 text-sm font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg transition-colors', // 警告操作按钮(黄色)- 用于批量禁用等 - warning: 'px-3 py-1.5 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-lg transition-colors', + warning: + 'px-3 py-1.5 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-lg transition-colors', // 小尺寸主要按钮 - primarySmall: 'px-2 py-1 text-xs font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-md transition-colors', + primarySmall: + 'px-2 py-1 text-xs font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-md transition-colors', // 小尺寸成功按钮 - successSmall: 'px-2 py-1 text-xs font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-md transition-colors', + successSmall: + 'px-2 py-1 text-xs font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-md transition-colors', // 小尺寸危险按钮 - dangerSmall: 'px-2 py-1 text-xs font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-md transition-colors', + dangerSmall: + 'px-2 py-1 text-xs font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-md transition-colors', // 小尺寸次要按钮 - secondarySmall: 'px-2 py-1 text-xs font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-md transition-colors', + secondarySmall: + 'px-2 py-1 text-xs font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-md transition-colors', // 小尺寸警告按钮 - warningSmall: 'px-2 py-1 text-xs font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-md transition-colors', + warningSmall: + 'px-2 py-1 text-xs font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-md transition-colors', // 圆角小按钮(用于表格操作) - roundedPrimary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 dark:text-blue-200 transition-colors', - roundedSuccess: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900/40 dark:hover:bg-green-900/60 dark:text-green-200 transition-colors', - roundedDanger: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-200 transition-colors', - roundedSecondary: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors', - roundedWarning: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:hover:bg-yellow-900/60 dark:text-yellow-200 transition-colors', - roundedPurple: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:hover:bg-purple-900/60 dark:text-purple-200 transition-colors', + roundedPrimary: + 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 dark:text-blue-200 transition-colors', + roundedSuccess: + 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900/40 dark:hover:bg-green-900/60 dark:text-green-200 transition-colors', + roundedDanger: + 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-200 transition-colors', + roundedSecondary: + 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors', + roundedWarning: + 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:hover:bg-yellow-900/60 dark:text-yellow-200 transition-colors', + roundedPurple: + 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:hover:bg-purple-900/60 dark:text-purple-200 transition-colors', // 禁用状态 - disabled: 'px-3 py-1.5 text-sm font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg transition-colors', - disabledSmall: 'px-2 py-1 text-xs font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-md transition-colors', + disabled: + 'px-3 py-1.5 text-sm font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg transition-colors', + disabledSmall: + 'px-2 py-1 text-xs font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-md transition-colors', // 开关按钮样式 toggleOn: 'bg-green-600 dark:bg-green-600', toggleOff: 'bg-gray-200 dark:bg-gray-700', @@ -86,7 +104,8 @@ const buttonStyles = { toggleThumbOn: 'translate-x-6', toggleThumbOff: 'translate-x-1', // 快速操作按钮样式 - quickAction: 'px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors', + quickAction: + 'px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors', }; // 通用弹窗组件 @@ -107,7 +126,7 @@ const AlertModal = ({ title, message, timer, - showConfirm = false + showConfirm = false, }: AlertModalProps) => { const [isVisible, setIsVisible] = useState(false); @@ -129,11 +148,11 @@ const AlertModal = ({ const getIcon = () => { switch (type) { case 'success': - return ; + return ; case 'error': - return ; + return ; case 'warning': - return ; + return ; default: return null; } @@ -153,21 +172,25 @@ const AlertModal = ({ }; return createPortal( -
-
-
-
- {getIcon()} -
+
+
+
+
{getIcon()}
-

+

{title}

{message && ( -

- {message} -

+

{message}

)} {showConfirm && ( @@ -205,7 +228,7 @@ const useAlertModal = () => { }; const hideAlert = () => { - setAlertModal(prev => ({ ...prev, isOpen: false })); + setAlertModal((prev) => ({ ...prev, isOpen: false })); }; return { alertModal, showAlert, hideAlert }; @@ -237,12 +260,15 @@ const useLoadingState = () => { const [loadingStates, setLoadingStates] = useState({}); const setLoading = (key: string, loading: boolean) => { - setLoadingStates(prev => ({ ...prev, [key]: loading })); + setLoadingStates((prev) => ({ ...prev, [key]: loading })); }; const isLoading = (key: string) => loadingStates[key] || false; - const withLoading = async (key: string, operation: () => Promise): Promise => { + const withLoading = async ( + key: string, + operation: () => Promise + ): Promise => { setLoading(key, true); try { const result = await operation(); @@ -364,10 +390,12 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { const [newUserGroup, setNewUserGroup] = useState({ name: '', enabledApis: [] as string[], + safeSearchApi: '', }); const [editingUserGroup, setEditingUserGroup] = useState<{ name: string; enabledApis: string[]; + safeSearchApi?: string; } | null>(null); const [showConfigureApisModal, setShowConfigureApisModal] = useState(false); const [selectedUser, setSelectedUser] = useState<{ @@ -377,7 +405,8 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { tags?: string[]; } | null>(null); const [selectedApis, setSelectedApis] = useState([]); - const [showConfigureUserGroupModal, setShowConfigureUserGroupModal] = useState(false); + const [showConfigureUserGroupModal, setShowConfigureUserGroupModal] = + useState(false); const [selectedUserForGroup, setSelectedUserForGroup] = useState<{ username: string; role: 'user' | 'admin' | 'owner'; @@ -387,10 +416,14 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { const [selectedUsers, setSelectedUsers] = useState>(new Set()); const [showBatchUserGroupModal, setShowBatchUserGroupModal] = useState(false); const [selectedUserGroup, setSelectedUserGroup] = useState(''); - const [showDeleteUserGroupModal, setShowDeleteUserGroupModal] = useState(false); + const [showDeleteUserGroupModal, setShowDeleteUserGroupModal] = + useState(false); const [deletingUserGroup, setDeletingUserGroup] = useState<{ name: string; - affectedUsers: Array<{ username: string; role: 'user' | 'admin' | 'owner' }>; + affectedUsers: Array<{ + username: string; + role: 'user' | 'admin' | 'owner'; + }>; } | null>(null); const [showDeleteUserModal, setShowDeleteUserModal] = useState(false); const [deletingUser, setDeletingUser] = useState(null); @@ -400,58 +433,71 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { // 使用 useMemo 计算全选状态,避免每次渲染都重新计算 const selectAllUsers = useMemo(() => { - const selectableUserCount = config?.UserConfig?.Users?.filter(user => - (role === 'owner' || - (role === 'admin' && - (user.role === 'user' || - user.username === currentUsername))) - ).length || 0; + const selectableUserCount = + config?.UserConfig?.Users?.filter( + (user) => + role === 'owner' || + (role === 'admin' && + (user.role === 'user' || user.username === currentUsername)) + ).length || 0; return selectedUsers.size === selectableUserCount && selectedUsers.size > 0; }, [selectedUsers.size, config?.UserConfig?.Users, role, currentUsername]); // 获取用户组列表 const userGroups = config?.UserConfig?.Tags || []; - const getGroupAllowedApiKeys = useCallback((tags?: string[]) => { - if (!tags || tags.length === 0) return null; + const getGroupAllowedApiKeys = useCallback( + (tags?: string[]) => { + if (!tags || tags.length === 0) return null; - const allowedApiKeys = new Set(); - tags.forEach((tagName) => { - const group = userGroups.find((item) => item.name === tagName); - group?.enabledApis?.forEach((apiKey) => allowedApiKeys.add(apiKey)); - }); - return allowedApiKeys; - }, [userGroups]); + const allowedApiKeys = new Set(); + tags.forEach((tagName) => { + const group = userGroups.find((item) => item.name === tagName); + group?.enabledApis?.forEach((apiKey) => allowedApiKeys.add(apiKey)); + }); + return allowedApiKeys; + }, + [userGroups] + ); - const getSelectableSourcesForUser = useCallback((user?: { tags?: string[] } | null) => { - const sources = config?.SourceConfig || []; - const groupAllowedApiKeys = getGroupAllowedApiKeys(user?.tags); + const getSelectableSourcesForUser = useCallback( + (user?: { tags?: string[] } | null) => { + const sources = config?.SourceConfig || []; + const groupAllowedApiKeys = getGroupAllowedApiKeys(user?.tags); - if (!groupAllowedApiKeys) { - return sources; - } + if (!groupAllowedApiKeys) { + return sources; + } - return sources.filter((source) => groupAllowedApiKeys.has(source.key)); - }, [config?.SourceConfig, getGroupAllowedApiKeys]); + return sources.filter((source) => groupAllowedApiKeys.has(source.key)); + }, + [config?.SourceConfig, getGroupAllowedApiKeys] + ); - const getEffectiveUserApiCount = useCallback((user: { enabledApis?: string[]; tags?: string[] }) => { - if (!user.enabledApis || user.enabledApis.length === 0) { - return null; - } + const getEffectiveUserApiCount = useCallback( + (user: { enabledApis?: string[]; tags?: string[] }) => { + if (!user.enabledApis || user.enabledApis.length === 0) { + return null; + } - const groupAllowedApiKeys = getGroupAllowedApiKeys(user.tags); - if (!groupAllowedApiKeys) { - return user.enabledApis.length; - } + const groupAllowedApiKeys = getGroupAllowedApiKeys(user.tags); + if (!groupAllowedApiKeys) { + return user.enabledApis.length; + } - return user.enabledApis.filter((apiKey) => groupAllowedApiKeys.has(apiKey)).length; - }, [getGroupAllowedApiKeys]); + return user.enabledApis.filter((apiKey) => + groupAllowedApiKeys.has(apiKey) + ).length; + }, + [getGroupAllowedApiKeys] + ); // 处理用户组相关操作 const handleUserGroupAction = async ( action: 'add' | 'edit' | 'delete', groupName: string, - enabledApis?: string[] + enabledApis?: string[], + safeSearchApi?: string ) => { return withLoading(`userGroup_${action}_${groupName}`, async () => { try { @@ -463,6 +509,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { groupAction: action, groupName, enabledApis, + safeSearchApi, }), }); @@ -474,14 +521,21 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { await refreshConfig(); if (action === 'add') { - setNewUserGroup({ name: '', enabledApis: [] }); + setNewUserGroup({ name: '', enabledApis: [], safeSearchApi: '' }); setShowAddUserGroupForm(false); } else if (action === 'edit') { setEditingUserGroup(null); setShowEditUserGroupForm(false); } - showSuccess(action === 'add' ? '用户组添加成功' : action === 'edit' ? '用户组更新成功' : '用户组删除成功', showAlert); + showSuccess( + action === 'add' + ? '用户组添加成功' + : action === 'edit' + ? '用户组更新成功' + : '用户组删除成功', + showAlert + ); } catch (err) { showError(err instanceof Error ? err.message : '操作失败', showAlert); throw err; @@ -491,23 +545,37 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { const handleAddUserGroup = () => { if (!newUserGroup.name.trim()) return; - handleUserGroupAction('add', newUserGroup.name, newUserGroup.enabledApis); + handleUserGroupAction( + 'add', + newUserGroup.name, + newUserGroup.enabledApis, + newUserGroup.safeSearchApi + ); }; const handleEditUserGroup = () => { if (!editingUserGroup?.name.trim()) return; - handleUserGroupAction('edit', editingUserGroup.name, editingUserGroup.enabledApis); + handleUserGroupAction( + 'edit', + editingUserGroup.name, + editingUserGroup.enabledApis, + editingUserGroup.safeSearchApi + ); }; const handleDeleteUserGroup = (groupName: string) => { // 计算会受影响的用户数量 - const affectedUsers = config?.UserConfig?.Users?.filter(user => - user.tags && user.tags.includes(groupName) - ) || []; + const affectedUsers = + config?.UserConfig?.Users?.filter( + (user) => user.tags && user.tags.includes(groupName) + ) || []; setDeletingUserGroup({ name: groupName, - affectedUsers: affectedUsers.map(u => ({ username: u.username, role: u.role })) + affectedUsers: affectedUsers.map((u) => ({ + username: u.username, + role: u.role, + })), }); setShowDeleteUserGroupModal(true); }; @@ -524,14 +592,21 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { } }; - const handleStartEditUserGroup = (group: { name: string; enabledApis: string[] }) => { - setEditingUserGroup({ ...group }); + const handleStartEditUserGroup = (group: { + name: string; + enabledApis: string[]; + safeSearchApi?: string; + }) => { + setEditingUserGroup({ ...group, safeSearchApi: group.safeSearchApi || '' }); setShowEditUserGroupForm(true); setShowAddUserGroupForm(false); }; // 为用户分配用户组 - const handleAssignUserGroup = async (username: string, userGroups: string[]) => { + const handleAssignUserGroup = async ( + username: string, + userGroups: string[] + ) => { return withLoading(`assignUserGroup_${username}`, async () => { try { const res = await fetch('/api/admin/user', { @@ -563,21 +638,32 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { }; const handleUnbanUser = async (uname: string) => { - await withLoading(`unbanUser_${uname}`, () => handleUserAction('unban', uname)); + await withLoading(`unbanUser_${uname}`, () => + handleUserAction('unban', uname) + ); }; const handleSetAdmin = async (uname: string) => { - await withLoading(`setAdmin_${uname}`, () => handleUserAction('setAdmin', uname)); + await withLoading(`setAdmin_${uname}`, () => + handleUserAction('setAdmin', uname) + ); }; const handleRemoveAdmin = async (uname: string) => { - await withLoading(`removeAdmin_${uname}`, () => handleUserAction('cancelAdmin', uname)); + await withLoading(`removeAdmin_${uname}`, () => + handleUserAction('cancelAdmin', uname) + ); }; const handleAddUser = async () => { if (!newUser.username || !newUser.password) return; await withLoading('addUser', async () => { - await handleUserAction('add', newUser.username, newUser.password, newUser.userGroup); + await handleUserAction( + 'add', + newUser.username, + newUser.password, + newUser.userGroup + ); setNewUser({ username: '', password: '', userGroup: '' }); setShowAddUserForm(false); }); @@ -585,15 +671,18 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { const handleChangePassword = async () => { if (!changePasswordUser.username || !changePasswordUser.password) return; - await withLoading(`changePassword_${changePasswordUser.username}`, async () => { - await handleUserAction( - 'changePassword', - changePasswordUser.username, - changePasswordUser.password - ); - setChangePasswordUser({ username: '', password: '' }); - setShowChangePasswordForm(false); - }); + await withLoading( + `changePassword_${changePasswordUser.username}`, + async () => { + await handleUserAction( + 'changePassword', + changePasswordUser.username, + changePasswordUser.password + ); + setChangePasswordUser({ username: '', password: '' }); + setShowChangePasswordForm(false); + } + ); }; const handleShowChangePasswordForm = (username: string) => { @@ -617,7 +706,9 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { setSelectedUser(user); setSelectedApis( groupAllowedApiKeys - ? (user.enabledApis || []).filter((apiKey) => groupAllowedApiKeys.has(apiKey)) + ? (user.enabledApis || []).filter((apiKey) => + groupAllowedApiKeys.has(apiKey) + ) : user.enabledApis || [] ); setShowConfigureApisModal(true); @@ -636,21 +727,27 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { const handleSaveUserGroups = async () => { if (!selectedUserForGroup) return; - await withLoading(`saveUserGroups_${selectedUserForGroup.username}`, async () => { - try { - await handleAssignUserGroup(selectedUserForGroup.username, selectedUserGroups); - setShowConfigureUserGroupModal(false); - setSelectedUserForGroup(null); - setSelectedUserGroups([]); - } catch (err) { - // 错误处理已在 handleAssignUserGroup 中处理 + await withLoading( + `saveUserGroups_${selectedUserForGroup.username}`, + async () => { + try { + await handleAssignUserGroup( + selectedUserForGroup.username, + selectedUserGroups + ); + setShowConfigureUserGroupModal(false); + setSelectedUserForGroup(null); + setSelectedUserGroups([]); + } catch (err) { + // 错误处理已在 handleAssignUserGroup 中处理 + } } - }); + ); }; // 处理用户选择 const handleSelectUser = useCallback((username: string, checked: boolean) => { - setSelectedUsers(prev => { + setSelectedUsers((prev) => { const newSelectedUsers = new Set(prev); if (checked) { newSelectedUsers.add(username); @@ -661,20 +758,24 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { }); }, []); - const handleSelectAllUsers = useCallback((checked: boolean) => { - if (checked) { - // 只选择自己有权限操作的用户 - const selectableUsernames = config?.UserConfig?.Users?.filter(user => - (role === 'owner' || - (role === 'admin' && - (user.role === 'user' || - user.username === currentUsername))) - ).map(u => u.username) || []; - setSelectedUsers(new Set(selectableUsernames)); - } else { - setSelectedUsers(new Set()); - } - }, [config?.UserConfig?.Users, role, currentUsername]); + const handleSelectAllUsers = useCallback( + (checked: boolean) => { + if (checked) { + // 只选择自己有权限操作的用户 + const selectableUsernames = + config?.UserConfig?.Users?.filter( + (user) => + role === 'owner' || + (role === 'admin' && + (user.role === 'user' || user.username === currentUsername)) + ).map((u) => u.username) || []; + setSelectedUsers(new Set(selectableUsernames)); + } else { + setSelectedUsers(new Set()); + } + }, + [config?.UserConfig?.Users, role, currentUsername] + ); // 批量设置用户组 const handleBatchSetUserGroup = async (userGroup: string) => { @@ -701,7 +802,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { setSelectedUsers(new Set()); setShowBatchUserGroupModal(false); setSelectedUserGroup(''); - showSuccess(`已为 ${userCount} 个用户设置用户组: ${userGroup}`, showAlert); + showSuccess( + `已为 ${userCount} 个用户设置用户组: ${userGroup}`, + showAlert + ); // 刷新配置 await refreshConfig(); @@ -712,8 +816,6 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { }); }; - - // 提取URL域名的辅助函数 const extractDomain = (url: string): string => { try { @@ -834,8 +936,6 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
- - {/* 用户组管理 */}
@@ -850,7 +950,11 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { setEditingUserGroup(null); } }} - className={showAddUserGroupForm ? buttonStyles.secondary : buttonStyles.primary} + className={ + showAddUserGroupForm + ? buttonStyles.secondary + : buttonStyles.primary + } > {showAddUserGroupForm ? '取消' : '添加用户组'} @@ -874,7 +978,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { {userGroups.map((group) => ( - + {group.name} @@ -891,7 +998,11 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { @@ -906,7 +1017,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { ))} {userGroups.length === 0 && ( - + 暂无用户组,请添加用户组来管理用户权限 @@ -948,7 +1062,9 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { setChangePasswordUser({ username: '', password: '' }); } }} - className={showAddUserForm ? buttonStyles.secondary : buttonStyles.success} + className={ + showAddUserForm ? buttonStyles.secondary : buttonStyles.success + } > {showAddUserForm ? '取消' : '添加用户'} @@ -965,7 +1081,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { placeholder='用户名' value={newUser.username} onChange={(e) => - setNewUser((prev) => ({ ...prev, username: e.target.value })) + setNewUser((prev) => ({ + ...prev, + username: e.target.value, + })) } className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' /> @@ -974,7 +1093,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { placeholder='密码' value={newUser.password} onChange={(e) => - setNewUser((prev) => ({ ...prev, password: e.target.value })) + setNewUser((prev) => ({ + ...prev, + password: e.target.value, + })) } className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent' /> @@ -986,14 +1108,21 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { @@ -1001,8 +1130,18 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
@@ -1039,10 +1178,20 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { />