diff --git a/README.md b/README.md index 2018ca9..ef627b9 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ - **📖 播放历史**:自动记录观看历史,快速找回看过的内容 - **👥 多用户支持**:独立的用户系统,每个用户独享个人数据 - **🔄 数据同步**:支持多种存储后端(LocalStorage、Redis、D1、Upstash) +- **🔒 内容过滤**:智能成人内容过滤系统,默认开启安全保护 ### 🚀 部署特性 @@ -586,6 +587,54 @@ docker run -d \ ## 📱 高级功能使用指南 +### 🔒 成人内容过滤 + +**功能介绍**: +- 智能识别和过滤成人内容资源站 +- 用户可自主选择开启或关闭过滤功能 +- 默认开启过滤,确保安全浏览体验 +- 支持资源分组显示,避免误触 + +**使用方法**: + +1. **访问用户设置**: + - 登录后访问 `/settings` 页面 + - 或在用户菜单中点击「用户设置」 + +2. **配置过滤选项**: + - 在「内容过滤」部分找到「成人内容过滤」开关 + - **开启**:完全隐藏成人内容资源站和搜索结果 + - **关闭**:成人内容在搜索结果中单独分组显示 + +3. **搜索结果展示**: + - **过滤开启时**:只显示常规内容 + - **过滤关闭时**:显示两个标签页「常规结果」和「成人内容」 + +**配置文件格式**: + +```json +// config.json 中的资源站配置 +{ + "api_site": { + "regular_site": { + "api": "https://example.com/api.php/provide/vod", + "name": "常规影视站", + "is_adult": false // 或省略此字段,默认为 false + }, + "adult_site": { + "api": "https://adult.example.com/api.php/provide/vod", + "name": "成人内容站", + "is_adult": true // 标记为成人内容 + } + } +} +``` + +**安全提示**: +- 默认情况下,所有新用户和未登录用户的成人内容过滤均为开启状态 +- 关闭过滤功能需要用户主动操作,确保使用意图明确 +- 建议管理员在配置资源站时准确标记 `is_adult` 字段 + ### 🎯 跳过片头片尾 **功能介绍**: diff --git a/config.json b/config.json index 6bd8714..559ff10 100644 --- a/config.json +++ b/config.json @@ -4,7 +4,14 @@ "example_test": { "api": "https://example.com/api.php/provide/vod", "name": "示例视频源", - "detail": "https://example.com" + "detail": "https://example.com", + "is_adult": false + }, + "adult_example": { + "api": "https://adult-example.com/api.php/provide/vod", + "name": "成人内容源(仅供示例)", + "detail": "https://adult-example.com", + "is_adult": true } } } diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 62e2b70..4d573bc 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import { getAvailableApiSites, getCacheTime } from '@/lib/config'; +import { getAdultApiSites, getAvailableApiSites, getCacheTime } from '@/lib/config'; import { addCorsHeaders, handleOptionsRequest } from '@/lib/cors'; +import { getStorage } from '@/lib/db'; import { searchFromApi } from '@/lib/downstream'; export const runtime = 'edge'; @@ -14,11 +15,16 @@ export async function OPTIONS() { export async function GET(request: Request) { const { searchParams } = new URL(request.url); const query = searchParams.get('q'); + const includeAdult = searchParams.get('include_adult') === 'true'; + const userName = searchParams.get('user'); // 用于获取用户设置 if (!query) { const cacheTime = await getCacheTime(); const response = NextResponse.json( - { results: [] }, + { + regular_results: [], + adult_results: [] + }, { headers: { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, @@ -30,16 +36,36 @@ export async function GET(request: Request) { return addCorsHeaders(response); } - const apiSites = await getAvailableApiSites(); - const searchPromises = apiSites.map((site) => searchFromApi(site, query)); - try { - const results = await Promise.all(searchPromises); - const flattenedResults = results.flat(); - const cacheTime = await getCacheTime(); + // 获取用户设置以确定是否需要过滤成人内容 + let shouldFilterAdult = true; // 默认过滤成人内容 + + if (userName) { + const storage = getStorage(); + const userSettings = await storage.getUserSettings(userName); + shouldFilterAdult = userSettings?.filter_adult_content !== false; + } + // 获取常规资源站 + const regularSites = await getAvailableApiSites(true); // 总是过滤成人内容 + const regularSearchPromises = regularSites.map((site) => searchFromApi(site, query)); + const regularResults = (await Promise.all(regularSearchPromises)).flat(); + + let adultResults: unknown[] = []; + + // 如果用户设置允许且明确请求包含成人内容,则搜索成人资源站 + if (!shouldFilterAdult && includeAdult) { + const adultSites = await getAdultApiSites(); + const adultSearchPromises = adultSites.map((site) => searchFromApi(site, query)); + adultResults = (await Promise.all(adultSearchPromises)).flat(); + } + + const cacheTime = await getCacheTime(); const response = NextResponse.json( - { results: flattenedResults }, + { + regular_results: regularResults, + adult_results: adultResults + }, { headers: { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, @@ -50,7 +76,14 @@ export async function GET(request: Request) { ); return addCorsHeaders(response); } catch (error) { - const response = NextResponse.json({ error: '搜索失败' }, { status: 500 }); + const response = NextResponse.json( + { + regular_results: [], + adult_results: [], + error: '搜索失败' + }, + { status: 500 } + ); return addCorsHeaders(response); } } diff --git a/src/app/api/user/settings/route.ts b/src/app/api/user/settings/route.ts new file mode 100644 index 0000000..9ab9a44 --- /dev/null +++ b/src/app/api/user/settings/route.ts @@ -0,0 +1,128 @@ +import { headers } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +import { getStorage } from '@/lib/db'; +import { UserSettings } from '@/lib/types'; + +// 获取用户设置 +export async function GET(_request: NextRequest) { + try { + const headersList = headers(); + const authorization = headersList.get('Authorization'); + + if (!authorization) { + return NextResponse.json({ error: '未授权访问' }, { status: 401 }); + } + + const userName = authorization.split(' ')[1]; // 假设格式为 "Bearer username" + + if (!userName) { + return NextResponse.json({ error: '用户名不能为空' }, { status: 400 }); + } + + const storage = getStorage(); + const settings = await storage.getUserSettings(userName); + + return NextResponse.json({ + settings: settings || { + filter_adult_content: true, // 默认开启成人内容过滤 + theme: 'auto', + language: 'zh-CN', + auto_play: true, + video_quality: 'auto' + } + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error getting user settings:', error); + return NextResponse.json({ error: '获取用户设置失败' }, { status: 500 }); + } +} + +// 更新用户设置 +export async function PATCH(request: NextRequest) { + try { + const headersList = headers(); + const authorization = headersList.get('Authorization'); + + if (!authorization) { + return NextResponse.json({ error: '未授权访问' }, { status: 401 }); + } + + const userName = authorization.split(' ')[1]; + + if (!userName) { + return NextResponse.json({ error: '用户名不能为空' }, { status: 400 }); + } + + const body = await request.json(); + const { settings } = body as { settings: Partial }; + + if (!settings) { + return NextResponse.json({ error: '设置数据不能为空' }, { status: 400 }); + } + + const storage = getStorage(); + + // 验证用户存在 + const userExists = await storage.checkUserExist(userName); + if (!userExists) { + return NextResponse.json({ error: '用户不存在' }, { status: 404 }); + } + + await storage.updateUserSettings(userName, settings); + + return NextResponse.json({ + success: true, + message: '设置更新成功' + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error updating user settings:', error); + return NextResponse.json({ error: '更新用户设置失败' }, { status: 500 }); + } +} + +// 重置用户设置 +export async function PUT(request: NextRequest) { + try { + const headersList = headers(); + const authorization = headersList.get('Authorization'); + + if (!authorization) { + return NextResponse.json({ error: '未授权访问' }, { status: 401 }); + } + + const userName = authorization.split(' ')[1]; + + if (!userName) { + return NextResponse.json({ error: '用户名不能为空' }, { status: 400 }); + } + + const body = await request.json(); + const { settings } = body as { settings: UserSettings }; + + if (!settings) { + return NextResponse.json({ error: '设置数据不能为空' }, { status: 400 }); + } + + const storage = getStorage(); + + // 验证用户存在 + const userExists = await storage.checkUserExist(userName); + if (!userExists) { + return NextResponse.json({ error: '用户不存在' }, { status: 404 }); + } + + await storage.setUserSettings(userName, settings); + + return NextResponse.json({ + success: true, + message: '设置已重置' + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error resetting user settings:', error); + return NextResponse.json({ error: '重置用户设置失败' }, { status: 500 }); + } +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..5a6caab --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +import { ArrowLeft, Settings, User } from 'lucide-react'; + +import AdultContentFilter from '@/components/AdultContentFilter'; +import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; + +export default function UserSettingsPage() { + const router = useRouter(); + const [authInfo, setAuthInfo] = useState<{ userName: string } | null>(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const auth = getAuthInfoFromBrowserCookie(); + if (!auth || !auth.username) { + // 如果用户未登录,重定向到登录页面 + router.push('/login'); + return; + } + setAuthInfo({ userName: auth.username }); + setIsLoading(false); + }, [router]); + + const handleFilterUpdate = (_enabled: boolean) => { + // 可以在这里添加一些全局状态更新或通知逻辑 + // console.log('成人内容过滤状态已更新:', enabled); + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!authInfo) { + return null; + } + + return ( +
+
+ {/* 页面头部 */} +
+
+ +
+

+ + 用户设置 +

+

+ 管理您的个人偏好设置和隐私选项 +

+
+
+ +
+ + + {authInfo.userName} + +
+
+ + {/* 设置区域 */} +
+ {/* 内容过滤设置 */} +
+

+ 内容过滤 +

+ +
+ + {/* 其他设置部分预留 */} +
+

+ 其他设置 +

+
+

+ 更多设置选项即将推出... +

+
+
+
+ + {/* 底部信息 */} +
+

设置会自动保存并在所有设备间同步

+
+
+
+ ); +} diff --git a/src/components/AdultContentFilter.tsx b/src/components/AdultContentFilter.tsx new file mode 100644 index 0000000..f6091bb --- /dev/null +++ b/src/components/AdultContentFilter.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { Shield, ShieldOff } from 'lucide-react'; + +interface AdultContentFilterProps { + userName: string; + onUpdate?: (enabled: boolean) => void; +} + +const AdultContentFilter: React.FC = ({ + userName, + onUpdate +}) => { + const [isEnabled, setIsEnabled] = useState(true); // 默认开启过滤 + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // 获取用户设置 + useEffect(() => { + const fetchUserSettings = async () => { + if (!userName) return; + + try { + const response = await fetch('/api/user/settings', { + headers: { + 'Authorization': `Bearer ${userName}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + setIsEnabled(data.settings.filter_adult_content); + } else { + setError('获取用户设置失败'); + } + } catch (err) { + setError('网络连接失败'); + // eslint-disable-next-line no-console + console.error('Failed to fetch user settings:', err); + } + }; + + fetchUserSettings(); + }, [userName]); + + // 更新用户设置 + const handleToggle = async () => { + if (!userName || isLoading) return; + + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/user/settings', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${userName}`, + }, + body: JSON.stringify({ + settings: { + filter_adult_content: !isEnabled, + }, + }), + }); + + if (response.ok) { + const newState = !isEnabled; + setIsEnabled(newState); + onUpdate?.(newState); + } else { + const errorData = await response.json(); + setError(errorData.error || '更新设置失败'); + } + } catch (err) { + setError('网络连接失败'); + // eslint-disable-next-line no-console + console.error('Failed to update user settings:', err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ {isEnabled ? ( + + ) : ( + + )} +
+
+

+ 成人内容过滤 +

+

+ {isEnabled + ? '已开启过滤,将自动隐藏所有标记为"成人"的资源站及其内容' + : '已关闭过滤,成人内容将在搜索结果中单独分组显示' + } +

+
+
+ +
+ + + {isLoading && ( +
+
+
+ )} +
+
+ + {error && ( +
+

{error}

+
+ )} + +
+
+
+ +
+
+

+ 安全提示 +

+

+ 为了确保良好的使用体验和遵守相关法规,建议保持成人内容过滤开启。如需访问相关内容,请确保您已年满18周岁并承担相应法律责任。 +

+
+
+
+
+ ); +}; + +export default AdultContentFilter; diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 56633b8..783dd81 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -22,6 +22,7 @@ export interface AdminConfig { detail?: string; from: 'config' | 'custom'; disabled?: boolean; + is_adult?: boolean; // 新增:是否为成人内容资源站 }[]; } diff --git a/src/lib/config.ts b/src/lib/config.ts index 7c976d6..9a1da30 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -378,9 +378,30 @@ export async function getCacheTime(): Promise { return config.SiteConfig.SiteInterfaceCacheTime || 7200; } -export async function getAvailableApiSites(): Promise { +export async function getAvailableApiSites(filterAdult = false): Promise { const config = await getConfig(); - return config.SourceConfig.filter((s) => !s.disabled).map((s) => ({ + let sites = config.SourceConfig.filter((s) => !s.disabled); + + // 如果需要过滤成人内容,则排除标记为成人内容的资源站 + if (filterAdult) { + sites = sites.filter((s) => !s.is_adult); + } + + return sites.map((s) => ({ + key: s.key, + name: s.name, + api: s.api, + detail: s.detail, + })); +} + +// 获取成人内容资源站 +export async function getAdultApiSites(): Promise { + const config = await getConfig(); + const adultSites = config.SourceConfig + .filter((s) => !s.disabled && s.is_adult); + + return adultSites.map((s) => ({ key: s.key, name: s.name, api: s.api, diff --git a/src/lib/localstorage.db.ts b/src/lib/localstorage.db.ts index 039eb7b..f38f7e0 100644 --- a/src/lib/localstorage.db.ts +++ b/src/lib/localstorage.db.ts @@ -1,6 +1,6 @@ /* eslint-disable no-console */ import { AdminConfig } from './admin.types'; -import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types'; +import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, UserSettings } from './types'; /** * LocalStorage 存储实现 @@ -290,6 +290,56 @@ export class LocalStorage implements IStorage { } } + // ---------- 用户设置 ---------- + async getUserSettings(userName: string): Promise { + if (typeof window === 'undefined') return null; + + try { + const storageKey = this.getStorageKey('settings', userName); + const data = localStorage.getItem(storageKey); + if (data) { + return JSON.parse(data); + } + + // 如果用户设置不存在,返回默认设置 + const defaultSettings: UserSettings = { + filter_adult_content: true, // 默认开启成人内容过滤 + theme: 'auto', + language: 'zh-CN', + auto_play: true, + video_quality: 'auto' + }; + + return defaultSettings; + } catch (error) { + console.error('Error getting user settings:', error); + return null; + } + } + + async setUserSettings(userName: string, settings: UserSettings): Promise { + if (typeof window === 'undefined') return; + + try { + const storageKey = this.getStorageKey('settings', userName); + localStorage.setItem(storageKey, JSON.stringify(settings)); + } catch (error) { + console.error('Error setting user settings:', error); + } + } + + async updateUserSettings(userName: string, settings: Partial): Promise { + if (typeof window === 'undefined') return; + + try { + const currentSettings = await this.getUserSettings(userName); + const updatedSettings = { ...currentSettings, ...settings }; + await this.setUserSettings(userName, updatedSettings as UserSettings); + } catch (error) { + console.error('Error updating user settings:', error); + } + } + // ---------- 管理员功能 ---------- async getAllUsers(): Promise { if (typeof window === 'undefined') return []; @@ -365,7 +415,7 @@ export class LocalStorage implements IStorage { localStorage.removeItem(userKey); // 删除用户相关的所有数据 - const prefixes = ['playrecord', 'favorite', 'searchhistory', 'skipconfig']; + const prefixes = ['playrecord', 'favorite', 'searchhistory', 'skipconfig', 'settings']; for (const prefix of prefixes) { const dataPrefix = this.getStorageKey(prefix, userName); diff --git a/src/lib/redis.db.ts b/src/lib/redis.db.ts index 7f61fd3..1a5e7dd 100644 --- a/src/lib/redis.db.ts +++ b/src/lib/redis.db.ts @@ -3,7 +3,7 @@ import { createClient, RedisClientType } from 'redis'; import { AdminConfig } from './admin.types'; -import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types'; +import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, UserSettings } from './types'; // 搜索历史最大条数 const SEARCH_HISTORY_LIMIT = 20; @@ -223,6 +223,50 @@ export class RedisStorage implements IStorage { if (favoriteKeys.length > 0) { await withRetry(() => this.client.del(favoriteKeys)); } + + // 删除用户设置 + await withRetry(() => this.client.del(this.userSettingsKey(userName))); + } + + // ---------- 用户设置 ---------- + private userSettingsKey(user: string) { + return `u:${user}:settings`; // u:username:settings + } + + async getUserSettings(userName: string): Promise { + const data = await withRetry(() => + this.client.get(this.userSettingsKey(userName)) + ); + + if (data) { + return JSON.parse(ensureString(data)); + } + + // 如果用户设置不存在,返回默认设置 + const defaultSettings: UserSettings = { + filter_adult_content: true, // 默认开启成人内容过滤 + theme: 'auto', + language: 'zh-CN', + auto_play: true, + video_quality: 'auto' + }; + + return defaultSettings; + } + + async setUserSettings(userName: string, settings: UserSettings): Promise { + await withRetry(() => + this.client.set( + this.userSettingsKey(userName), + JSON.stringify(settings) + ) + ); + } + + async updateUserSettings(userName: string, settings: Partial): Promise { + const currentSettings = await this.getUserSettings(userName); + const updatedSettings = { ...currentSettings, ...settings }; + await this.setUserSettings(userName, updatedSettings as UserSettings); } // ---------- 搜索历史 ---------- diff --git a/src/lib/types.ts b/src/lib/types.ts index 6a84139..9b00095 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -70,6 +70,11 @@ export interface IStorage { // 删除用户(包括密码、搜索历史、播放记录、收藏夹) deleteUser(userName: string): Promise; + // 用户设置相关 + getUserSettings(userName: string): Promise; + setUserSettings(userName: string, settings: UserSettings): Promise; + updateUserSettings(userName: string, settings: Partial): Promise; + // 搜索历史相关 getSearchHistory(userName: string): Promise; addSearchHistory(userName: string, keyword: string): Promise; @@ -119,6 +124,38 @@ export interface DoubanResult { list: DoubanItem[]; } +// 资源站配置 +export interface ApiSite { + api: string; + name: string; + detail?: string; + type?: number; + playMode?: 'parse' | 'direct'; + is_adult?: boolean; // 新增:是否为成人内容资源站 +} + +// 配置文件结构 +export interface Config { + cache_time: number; + api_site: { [key: string]: ApiSite }; +} + +// 用户设置 +export interface UserSettings { + filter_adult_content: boolean; // 是否过滤成人内容,默认为 true + theme: 'light' | 'dark' | 'auto'; + language: string; + auto_play: boolean; + video_quality: string; + [key: string]: string | boolean | number; // 允许其他设置 +} + +// 搜索结果(支持成人内容分组) +export interface GroupedSearchResults { + regular_results: SearchResult[]; + adult_results?: SearchResult[]; +} + // Runtime配置类型 export interface RuntimeConfig { STORAGE_TYPE?: string; diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index a807c51..f4ef3ce 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -3,7 +3,7 @@ import { Redis } from '@upstash/redis'; import { AdminConfig } from './admin.types'; -import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord } from './types'; +import { EpisodeSkipConfig, Favorite, IStorage, PlayRecord, UserSettings } from './types'; // 搜索历史最大条数 const SEARCH_HISTORY_LIMIT = 20;