From 787680468b36cbad1262830155c2e92ef386dd39 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Wed, 8 Jul 2026 23:28:25 +0800 Subject: [PATCH] feat: clarify proxy and add LAN access toggle --- CHANGELOG.md | 7 +++ Dockerfile | 2 +- README.md | 41 ++++++++++++++ app-release.json | 24 +++++++- components/settings/PlayerSettings.tsx | 76 +++++++++++++++----------- lib/config/lan-access.ts | 72 ++++++++++++++++++++++++ next.config.ts | 5 ++ package-lock.json | 4 +- package.json | 6 +- scripts/next-with-lan-access.mjs | 48 ++++++++++++++++ tests/lan-access.test.ts | 48 ++++++++++++++++ 11 files changed, 295 insertions(+), 38 deletions(-) create mode 100644 lib/config/lan-access.ts create mode 100644 scripts/next-with-lan-access.mjs create mode 100644 tests/lan-access.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f0447..c1d7b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 4.9.4 - 2026-07-08 + +- 设置页的代理播放区域显示内置 `/api/proxy` 端点和部署限制,明确该功能不是第三方 HTTP/SOCKS 代理服务器配置。 +- 新增 `ALLOW_LAN_ACCESS` 环境变量:开启后 `npm run dev` / `npm run start` 会绑定 `0.0.0.0`,开发模式会放行常见内网 IPv4 来源。 +- 新增 `LAN_ALLOWED_DEV_ORIGINS`,允许自定义 Next.js 开发模式的额外来源主机。 +- README 补充局域网访问和内置媒体代理说明,并同步修正 Docker hostname 注释。 + ## 4.9.3 - 2026-04-15 - 新增 Redis 托管账户模式:支持用户名密码登录、超级管理员账户 CRUD、权限编辑和密码重置。 diff --git a/Dockerfile b/Dockerfile index ee116a2..bd67474 100644 --- a/Dockerfile +++ b/Dockerfile @@ -79,7 +79,7 @@ USER nextjs EXPOSE 3000 ENV PORT=3000 -# set hostname to localhost +# Bind the standalone server to all container interfaces. ENV HOSTNAME="0.0.0.0" CMD ["node", "server.js"] diff --git a/README.md b/README.md index 7d19012..5b41e0e 100644 --- a/README.md +++ b/README.md @@ -611,6 +611,43 @@ PORT=8080 npm run start docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest ``` +## 局域网 IP 访问 + +通过 `ALLOW_LAN_ACCESS` 环境变量控制本机开发或传统 Node.js 自托管时是否允许同一局域网设备直接访问当前设备 IP。 + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `ALLOW_LAN_ACCESS` | 设为 `true` / `1` / `yes` / `on` 时,`npm run dev` 和 `npm run start` 会绑定 `0.0.0.0`,并在开发模式下放行常见内网 IPv4 来源;未开启时默认只绑定 `localhost` | `false` | +| `NEXT_PUBLIC_ALLOW_LAN_ACCESS` | `ALLOW_LAN_ACCESS` 的构建时兼容名称;优先使用 `ALLOW_LAN_ACCESS` | `false` | +| `LAN_ALLOWED_DEV_ORIGINS` | 追加 Next.js 开发模式允许访问 dev 资源的主机名或通配符,逗号、空格或换行分隔 | - | + +**示例:** + +```bash +# 开发模式允许同局域网设备访问 +ALLOW_LAN_ACCESS=true npm run dev + +# 传统 Node.js 生产模式允许同局域网设备访问 +ALLOW_LAN_ACCESS=true npm run start + +# 追加自定义开发来源;Next.js 需要主机名,不要填写路径 +ALLOW_LAN_ACCESS=true LAN_ALLOWED_DEV_ORIGINS="kvideo.lan,192.168.50.10" npm run dev +``` + +开启后访问地址通常是 `http://<当前设备内网 IP>:3000`。如果仍然无法访问,优先检查系统防火墙、路由器客户端隔离、端口映射、反向代理监听地址以及实际运行端口;这些不是 KVideo 前端代码可以绕过的问题。Docker 镜像已经在容器内绑定 `0.0.0.0`,是否能从局域网访问主要取决于 `-p` 端口映射和宿主机网络策略。 + +## 内置媒体代理说明 + +设置页的“代理播放模式”使用的是当前 KVideo 部署内置的 `/api/proxy` 媒体转发端点,不是让用户填写第三方 HTTP/SOCKS 代理服务器。 + +| 模式 | 行为 | +|------|------| +| 智能重试 | 优先直连,播放失败后自动切换到 `/api/proxy` | +| 仅直连 | 不使用 `/api/proxy`,失败时直接报错 | +| 总是代理 | 播放地址始终通过 `/api/proxy` 转发 | + +`/api/proxy?url=` 只在 Docker 或传统 Node.js 自托管完整模式下启用。Vercel / Cloudflare 托管部署运行合规模式,会禁用外部媒体代理、热链转发和 IPTV 流中继;这种部署环境下设置页会显示仅支持直连播放。 + ## 自定义源 JSON 格式 如果你想创建自己的订阅源或批量导入源,可以使用以下 JSON 格式。 @@ -689,6 +726,8 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest | `PREMIUM_PASSWORD` | 高级内容独立密码,访问 `/premium` 时需输入 | - | | `PERSIST_SESSION` | 是否持久化登录会话 | `true` | | `PORT` | 自定义应用端口 | `3000` | +| `ALLOW_LAN_ACCESS` / `NEXT_PUBLIC_ALLOW_LAN_ACCESS` | 允许同局域网设备通过当前设备 IP 访问本机开发或传统 Node.js 自托管服务 | `false` | +| `LAN_ALLOWED_DEV_ORIGINS` / `NEXT_PUBLIC_LAN_ALLOWED_DEV_ORIGINS` | 追加 Next.js 开发模式允许访问 dev 资源的主机名或通配符 | - | | `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` | | `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` | | `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` | @@ -823,6 +862,8 @@ npm start 应用将在 `http://localhost:3000` 启动。 +同一局域网设备需要直接访问当前设备 IP 时,使用 `ALLOW_LAN_ACCESS=true npm run start`,并确认系统防火墙允许入站访问对应端口。 + #### 选项 3:Vercel / Cloudflare 托管部署(合规模式) 适用于只需要搜索、账户、设置、直连播放等基础能力,不需要外部媒体代理 / IPTV 中继的场景。 diff --git a/app-release.json b/app-release.json index e87e30e..ad2e9ed 100644 --- a/app-release.json +++ b/app-release.json @@ -4,8 +4,30 @@ "name": "KVideo", "branch": "main" }, - "currentVersion": "4.9.2", + "currentVersion": "4.9.4", "releases": [ + { + "version": "4.9.4", + "publishedAt": "2026-07-08", + "title": "补充代理说明与局域网访问开关", + "notes": [ + "设置页的代理播放区域显示内置 /api/proxy 端点和部署限制,明确该功能不是第三方 HTTP/SOCKS 代理服务器配置。", + "新增 ALLOW_LAN_ACCESS 环境变量:开启后 npm run dev / npm run start 会绑定 0.0.0.0,开发模式会放行常见内网 IPv4 来源。", + "新增 LAN_ALLOWED_DEV_ORIGINS,允许自定义 Next.js 开发模式的额外来源主机。", + "README 补充局域网访问和内置媒体代理说明,并同步修正 Docker hostname 注释。" + ] + }, + { + "version": "4.9.3", + "publishedAt": "2026-04-15", + "title": "新增 Redis 托管账户模式", + "notes": [ + "新增 Redis 托管账户模式:支持用户名密码登录、超级管理员账户 CRUD、权限编辑和密码重置。", + "认证改为服务端签名 HTTP-only 会话 Cookie,/api/user/config 与 /api/user/sync 不再信任客户端自报 profileId。", + "播放页线路列表补齐剩余问题:分辨率探测会按当前集数探测并缓存,打开线路列表时会自动定位当前线路,必要时自动展开隐藏项。", + "仓库补充了与新认证和线路列表逻辑对应的单元测试,并修正了本地 eslint 版本与 Next.js 规则链不兼容的问题。" + ] + }, { "version": "4.9.2", "publishedAt": "2026-04-12", diff --git a/components/settings/PlayerSettings.tsx b/components/settings/PlayerSettings.tsx index 0e155d4..f709a34 100644 --- a/components/settings/PlayerSettings.tsx +++ b/components/settings/PlayerSettings.tsx @@ -170,37 +170,48 @@ export function PlayerSettings({ 控制视频播放时的网络请求策略

{mediaProxyEnabled ? ( -
- - - +
+
+ + + +
+
+
内置代理端点
+ + /api/proxy?url=<encoded-video-url> + +
+ 此处不是第三方 HTTP/SOCKS 代理配置。播放器会按上方模式把播放地址交给当前 KVideo 部署的内置代理;该能力只在 Docker 或传统 Node.js 自托管完整模式下启用。 +
+
) : (
@@ -208,6 +219,9 @@ export function PlayerSettings({
{restrictionSummary}
+
+ 内置代理端点为 /api/proxy,但当前部署模式已禁用该端点。 +
)}
diff --git a/lib/config/lan-access.ts b/lib/config/lan-access.ts new file mode 100644 index 0000000..3475363 --- /dev/null +++ b/lib/config/lan-access.ts @@ -0,0 +1,72 @@ +type EnvLike = Record; + +const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'y', 'on', 'enabled', 'enable']); + +export const LAN_ACCESS_ENV_KEYS = ['ALLOW_LAN_ACCESS', 'NEXT_PUBLIC_ALLOW_LAN_ACCESS'] as const; +export const LAN_ALLOWED_DEV_ORIGINS_ENV_KEYS = [ + 'LAN_ALLOWED_DEV_ORIGINS', + 'NEXT_PUBLIC_LAN_ALLOWED_DEV_ORIGINS', +] as const; + +export const DEFAULT_LAN_ALLOWED_DEV_ORIGINS = [ + '10.*.*.*', + ...Array.from({ length: 16 }, (_, index) => `172.${16 + index}.*.*`), + '192.168.*.*', +] as const; + +export function parseBooleanEnv(value: string | undefined | null): boolean { + if (!value) { + return false; + } + + return TRUE_ENV_VALUES.has(value.trim().toLowerCase()); +} + +export function isLanAccessEnabled(env: EnvLike = process.env): boolean { + return LAN_ACCESS_ENV_KEYS.some((key) => parseBooleanEnv(env[key])); +} + +function normalizeAllowedOriginHost(rawOrigin: string): string | null { + const trimmed = rawOrigin.trim(); + + if (!trimmed) { + return null; + } + + try { + const hostname = new URL(trimmed).hostname; + if (hostname) { + return hostname.toLowerCase(); + } + } catch { + // Fall through to host[:port] parsing. + } + + const withoutPort = trimmed.match(/^([^:/\s]+):\d+$/)?.[1] ?? trimmed; + return withoutPort.toLowerCase(); +} + +export function parseLanAllowedDevOrigins(value: string | undefined | null): string[] { + if (!value) { + return []; + } + + const origins = value + .split(/[\s,]+/) + .map(normalizeAllowedOriginHost) + .filter((origin): origin is string => Boolean(origin)); + + return [...new Set(origins)]; +} + +export function getLanAllowedDevOrigins(env: EnvLike = process.env): string[] { + if (!isLanAccessEnabled(env)) { + return []; + } + + const customOrigins = LAN_ALLOWED_DEV_ORIGINS_ENV_KEYS.flatMap((key) => + parseLanAllowedDevOrigins(env[key]) + ); + + return [...new Set([...DEFAULT_LAN_ALLOWED_DEV_ORIGINS, ...customOrigins])]; +} diff --git a/next.config.ts b/next.config.ts index 955d5ec..0b46ff3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,11 @@ import type { NextConfig } from "next"; +import { getLanAllowedDevOrigins } from "./lib/config/lan-access"; + +const lanAllowedDevOrigins = getLanAllowedDevOrigins(); const nextConfig: NextConfig = { + ...(lanAllowedDevOrigins.length > 0 ? { allowedDevOrigins: lanAllowedDevOrigins } : {}), + // Performance optimizations reactStrictMode: true, poweredByHeader: false, diff --git a/package-lock.json b/package-lock.json index 38ab6a6..3cdac9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.9.2", + "version": "4.9.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.9.2", + "version": "4.9.4", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 5ba6ba2..966330c 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "kvideo", - "version": "4.9.2", + "version": "4.9.4", "private": true, "scripts": { - "dev": "next dev --port ${PORT:-3000}", + "dev": "node scripts/next-with-lan-access.mjs dev", "build": "next build --webpack", - "start": "next start --port ${PORT:-3000}", + "start": "node scripts/next-with-lan-access.mjs start", "lint": "eslint", "test": "tsx --test tests/**/*.test.ts", "pages:build": "next-on-pages" diff --git a/scripts/next-with-lan-access.mjs b/scripts/next-with-lan-access.mjs new file mode 100644 index 0000000..cd0328c --- /dev/null +++ b/scripts/next-with-lan-access.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; + +const mode = process.argv[2]; +const forwardedArgs = process.argv.slice(3); +const validModes = new Set(['dev', 'start']); + +if (!validModes.has(mode)) { + console.error('Usage: node scripts/next-with-lan-access.mjs [next args...]'); + process.exit(1); +} + +function isTruthyEnv(value) { + if (!value) { + return false; + } + + return ['1', 'true', 'yes', 'y', 'on', 'enabled', 'enable'].includes( + value.trim().toLowerCase() + ); +} + +const lanAccessEnabled = isTruthyEnv( + process.env.ALLOW_LAN_ACCESS ?? process.env.NEXT_PUBLIC_ALLOW_LAN_ACCESS +); +const hostname = process.env.HOSTNAME || process.env.NEXT_HOSTNAME || (lanAccessEnabled ? '0.0.0.0' : 'localhost'); +const args = [mode, '--port', process.env.PORT || '3000']; + +if (hostname) { + args.push('--hostname', hostname); +} + +args.push(...forwardedArgs); + +const child = spawn('next', args, { + stdio: 'inherit', + shell: process.platform === 'win32', +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 0); +}); diff --git a/tests/lan-access.test.ts b/tests/lan-access.test.ts new file mode 100644 index 0000000..431341d --- /dev/null +++ b/tests/lan-access.test.ts @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + DEFAULT_LAN_ALLOWED_DEV_ORIGINS, + getLanAllowedDevOrigins, + isLanAccessEnabled, + parseBooleanEnv, + parseLanAllowedDevOrigins, +} from '@/lib/config/lan-access'; + +test('parseBooleanEnv only enables explicit truthy values', () => { + assert.equal(parseBooleanEnv('true'), true); + assert.equal(parseBooleanEnv(' ON '), true); + assert.equal(parseBooleanEnv('1'), true); + assert.equal(parseBooleanEnv('false'), false); + assert.equal(parseBooleanEnv('0'), false); + assert.equal(parseBooleanEnv(undefined), false); +}); + +test('isLanAccessEnabled supports server and public env names', () => { + assert.equal(isLanAccessEnabled({}), false); + assert.equal(isLanAccessEnabled({ ALLOW_LAN_ACCESS: 'true' }), true); + assert.equal(isLanAccessEnabled({ NEXT_PUBLIC_ALLOW_LAN_ACCESS: 'yes' }), true); +}); + +test('parseLanAllowedDevOrigins accepts hostnames, URLs, ports, and comma lists', () => { + assert.deepEqual( + parseLanAllowedDevOrigins('http://192.168.1.10:3000, local.kvideo.test:8080 *.lan.test'), + ['192.168.1.10', 'local.kvideo.test', '*.lan.test'] + ); +}); + +test('getLanAllowedDevOrigins returns defaults and custom origins only when enabled', () => { + assert.deepEqual(getLanAllowedDevOrigins({ LAN_ALLOWED_DEV_ORIGINS: '192.168.50.10' }), []); + + const origins = getLanAllowedDevOrigins({ + ALLOW_LAN_ACCESS: 'true', + LAN_ALLOWED_DEV_ORIGINS: '192.168.50.10', + }); + + assert.ok(origins.includes('10.*.*.*')); + assert.ok(origins.includes('172.16.*.*')); + assert.ok(origins.includes('172.31.*.*')); + assert.ok(origins.includes('192.168.*.*')); + assert.ok(origins.includes('192.168.50.10')); + assert.equal(origins.length, DEFAULT_LAN_ALLOWED_DEV_ORIGINS.length + 1); +});