feat: clarify proxy and add LAN access toggle

This commit is contained in:
kuekhaoyang
2026-07-08 23:28:25 +08:00
parent 0f39d65b75
commit 787680468b
11 changed files with 295 additions and 38 deletions
+7
View File
@@ -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、权限编辑和密码重置。
+1 -1
View File
@@ -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"]
+41
View File
@@ -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=<encoded-video-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`,并确认系统防火墙允许入站访问对应端口。
#### 选项 3Vercel / Cloudflare 托管部署(合规模式)
适用于只需要搜索、账户、设置、直连播放等基础能力,不需要外部媒体代理 / IPTV 中继的场景。
+23 -1
View File
@@ -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",
+14
View File
@@ -170,6 +170,7 @@ export function PlayerSettings({
</p>
{mediaProxyEnabled ? (
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
onClick={() => onProxyModeChange('retry')}
@@ -202,12 +203,25 @@ export function PlayerSettings({
<div className="text-sm opacity-80 mt-1"></div>
</button>
</div>
<div className="rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[color-mix(in_srgb,var(--glass-bg)_70%,transparent)] px-4 py-3">
<div className="text-sm font-semibold text-[var(--text-color)]"></div>
<code className="mt-1 block break-all rounded-[var(--radius-lg)] bg-black/20 px-3 py-2 text-xs text-[var(--text-color)]">
/api/proxy?url=&lt;encoded-video-url&gt;
</code>
<div className="mt-2 text-sm text-[var(--text-color-secondary)]">
HTTP/SOCKS KVideo Docker Node.js
</div>
</div>
</div>
) : (
<div className="rounded-[var(--radius-2xl)] border border-amber-500/30 bg-amber-500/10 px-4 py-3">
<div className="font-semibold text-[var(--text-color)]"></div>
<div className="text-sm text-[var(--text-color-secondary)] mt-1">
{restrictionSummary}
</div>
<div className="mt-2 text-xs text-[var(--text-color-secondary)]">
/api/proxy
</div>
</div>
)}
</div>
+72
View File
@@ -0,0 +1,72 @@
type EnvLike = Record<string, string | undefined>;
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])];
}
+5
View File
@@ -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,
+2 -2
View File
@@ -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",
+3 -3
View File
@@ -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"
+48
View File
@@ -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 <dev|start> [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);
});
+48
View File
@@ -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);
});