feat(docker): support redis managed accounts

This commit is contained in:
kuekhaoyang
2026-05-02 15:03:03 +08:00
parent d80cf3dfcf
commit ffabdbe0e4
15 changed files with 588 additions and 28 deletions
+3
View File
@@ -3,6 +3,9 @@ Dockerfile
node_modules
npm-debug.log
README.md
.env
.env.*
!.env.example
.next
.open-next
.vercel
+25
View File
@@ -0,0 +1,25 @@
# KVideo Docker / self-hosted configuration
#
# Managed accounts require both AUTH_SECRET and at least one bootstrap account
# from ADMIN_PASSWORD or ACCOUNTS. Generate a real secret before deployment:
# openssl rand -base64 32
AUTH_SECRET=
AUTH_COOKIE_SECURE=
ADMIN_PASSWORD=
ACCESS_PASSWORD=
ACCOUNTS=
PREMIUM_PASSWORD=
# Docker Compose starts a local Redis service and uses this URL by default.
# For an external Redis server, replace the value with redis:// or rediss://.
REDIS_URL=redis://redis:6379/0
# Cloudflare/Vercel-style managed deployments cannot use TCP Redis. Use
# Upstash REST credentials there instead. When both Upstash values are set,
# they take precedence over REDIS_URL.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
KVIDEO_PUBLIC_RELAY_ENABLED=false
KVIDEO_OUTBOUND_PRIVATE_HOST_ALLOWLIST=
+1
View File
@@ -36,6 +36,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## 4.9.4 - 2026-05-02
- Docker Compose 新增持久化 Redis 服务,默认通过 `REDIS_URL=redis://redis:6379/0` 启用自托管托管账户存储。
- 服务端 Redis 层新增普通 `redis://` / `rediss://` TCP Redis 支持;Cloudflare / Vercel 等受限托管环境仍使用 Upstash REST 凭据。
- 新增 `.env.example`,补齐 Docker 托管账户、Redis、Upstash 和中继相关环境变量说明。
- 会话 Cookie 的 `Secure` 标记改为按请求协议 / `x-forwarded-proto` 自动决定,并新增 `AUTH_COOKIE_SECURE` 强制开关,避免 HTTP Docker 直连登录后浏览器丢弃会话。
- README、Cloudflare 注释和部署文档补齐 Docker Redis 与 Cloudflare Upstash 的边界说明。
- 新增 TCP Redis 客户端回归测试,覆盖 JSON 存取、计数器、TTL、删除和缺失键行为。
## 4.9.3 - 2026-04-15
- 新增 Redis 托管账户模式:支持用户名密码登录、超级管理员账户 CRUD、权限编辑和密码重置。
+41 -14
View File
@@ -1,6 +1,6 @@
[![Upstream Sync](https://github.com/KuekHaoYang/KVideo/actions/workflows/Github_Upstream_Sync.yml/badge.svg)](https://github.com/KuekHaoYang/KVideo/actions/workflows/Github_Upstream_Sync.yml)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/KuekHaoYang/KVideo)
[![Version](https://img.shields.io/badge/Version-4.9.3-orange?style=for-the-badge)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-4.9.4-orange?style=for-the-badge)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-16.2.4-black?style=for-the-badge&logo=next.js)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.2.5-149ECA?style=for-the-badge&logo=react)](https://react.dev/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind-4.2.2-38B2AC?style=for-the-badge&logo=tailwind-css)](https://tailwindcss.com/)
@@ -15,7 +15,7 @@
> 一个基于 Next.js 16、React 19 和 Tailwind CSS v4 构建的现代化视频聚合播放平台,聚焦自托管部署、多源并行搜索、播放器体验、IPTV 播放、账户隔离和 Redis 同步能力。
**当前版本:[`4.9.3`](CHANGELOG.md)**
**当前版本:[`4.9.4`](CHANGELOG.md)**
> [!IMPORTANT]
> 这份 README 已按当前主分支和 2026-04-16 审计后的实际行为校正。旧版文案里关于 Apple TV 原生支持、宽松公共代理、以及“完整离线可用”的表述都已经不再成立。
@@ -145,7 +145,7 @@ Apple TV 用户当前应使用浏览器、AirPlay,或其他已经被支持的
- **环境变量兼容模式**:仍支持 `ADMIN_PASSWORD` / `ACCESS_PASSWORD` / `ACCOUNTS`
- **超级管理员账户管理**:创建、修改、重置、删除账户
- **独立高级内容密码**`PREMIUM_PASSWORD`
- **配置同步**:使用 Upstash Redis 在设备间同步设置
- **配置同步**Docker / Node.js 可使用普通 `redis://` / `rediss://`Cloudflare / Vercel 等受限托管环境使用 Upstash Redis REST
- **多账户数据隔离**:收藏、历史、设置、个人源、弹幕 API 都按 `profileId` 隔离
### 全端体验
@@ -178,8 +178,9 @@ Apple TV 用户当前应使用浏览器、AirPlay,或其他已经被支持的
启用条件:
- `AUTH_SECRET`
- `UPSTASH_REDIS_REST_URL`
- `UPSTASH_REDIS_REST_TOKEN`
- Docker / 自托管 Node.js`REDIS_URL``KVIDEO_REDIS_URL`
- Cloudflare / Vercel 等受限托管环境:`UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN`
- 至少一个首批账户来源:`ADMIN_PASSWORD``ACCOUNTS`
启用后:
@@ -236,6 +237,7 @@ Apple TV 用户当前应使用浏览器、AirPlay,或其他已经被支持的
| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| `AUTH_SECRET` | - | 启用认证时必填;缺失时受保护的认证/同步/代理流程不会正常工作 |
| `AUTH_COOKIE_SECURE` | 自动 | 可选强制值:`true``false`;未设置时按 `x-forwarded-proto` / 请求协议决定,避免 HTTP Docker 直连丢失会话 Cookie |
| `ADMIN_PASSWORD` | - | 管理员密码;兼容模式直接生效,也可作为托管模式首批超级管理员种子 |
| `ACCESS_PASSWORD` | - | `ADMIN_PASSWORD` 的兼容别名 |
| `ACCOUNTS` | - | 多账户配置,支持旧格式和用户名格式 |
@@ -254,8 +256,11 @@ Apple TV 用户当前应使用浏览器、AirPlay,或其他已经被支持的
| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| `UPSTASH_REDIS_REST_URL` | - | 与 Token 配对后启用托管账户与同步能力 |
| `UPSTASH_REDIS_REST_TOKEN` | - | 与 URL 配对后启用托管账户与同步能力 |
| `REDIS_URL` | - | 普通 TCP Redis 地址,仅用于自托管 Node.js / Docker;支持 `redis://``rediss://` |
| `KVIDEO_REDIS_URL` | - | KVideo 专用 TCP Redis 地址;存在时优先于 `REDIS_URL` |
| `KVIDEO_REDIS_CONNECT_TIMEOUT_MS` | `5000` | TCP Redis 单次命令连接超时,单位毫秒 |
| `UPSTASH_REDIS_REST_URL` | - | Upstash REST 地址;与 Token 配对后启用托管账户与同步能力 |
| `UPSTASH_REDIS_REST_TOKEN` | - | Upstash REST Token;与 URL 配对后优先于 TCP Redis |
### 站点标题与图标
@@ -295,7 +300,8 @@ Apple TV 用户当前应使用浏览器、AirPlay,或其他已经被支持的
| [hls.js](https://github.com/video-dev/hls.js/) | `1.6.16` | HLS 播放 |
| [Lucide React](https://lucide.dev/) | `0.577.0` | 图标库 |
| [@dnd-kit](https://dndkit.com/) | `6.x / 10.x` | 拖拽排序 |
| [@upstash/redis](https://github.com/upstash/redis-js) | `1.37.0` | Redis 同步与托管账户 |
| [@upstash/redis](https://github.com/upstash/redis-js) | `1.37.0` | Cloudflare / 托管环境 Redis REST 同步与托管账户 |
| TCP Redis (`redis://` / `rediss://`) | - | Docker / 自托管 Node.js 托管账户、同步与登录限流 |
| [opencc-js](https://github.com/nk2028/opencc-js) | `1.0.5` | 繁简转换辅助搜索 |
### 开发工具
@@ -331,21 +337,40 @@ npm start
### Docker 部署
```bash
docker build -t kvideo .
docker run -d -p 3000:3000 --name kvideo kvideo
cp .env.example .env
# 编辑 .env:至少填写 AUTH_SECRET,并用 ADMIN_PASSWORD 或 ACCOUNTS 种子化首批托管账户
docker compose up -d
```
或直接使用 Docker Compose
Compose 会同时启动
- `kvideo`Next.js standalone 服务
- `redis`:持久化 Redis,数据卷为 `kvideo-redis-data`
如果你不用 Compose,而是手动运行容器,必须自己提供可访问的 Redis:
```bash
docker compose up -d
docker build -t kvideo .
docker run -d \
-p 3000:3000 \
-e AUTH_SECRET="replace-with-a-real-secret" \
-e ADMIN_PASSWORD="replace-with-a-real-password" \
-e REDIS_URL="redis://your-redis-host:6379/0" \
--name kvideo \
kvideo
```
### Docker Hub 镜像
```bash
docker pull kuekhaoyang/kvideo:latest
docker run -d -p 3000:3000 --name kvideo kuekhaoyang/kvideo:latest
docker run -d \
-p 3000:3000 \
-e AUTH_SECRET="replace-with-a-real-secret" \
-e ADMIN_PASSWORD="replace-with-a-real-password" \
-e REDIS_URL="redis://your-redis-host:6379/0" \
--name kvideo \
kuekhaoyang/kvideo:latest
```
### 完整功能与受限功能的边界
@@ -380,6 +405,8 @@ npm run cf:preview
`npm run pages:build` 仍然保留,但只是给现存兼容链路兜底的遗留构建脚本,不是这条分支主张的 Cloudflare 主路径。
Cloudflare Workers 不能使用 Docker Compose 里的 TCP Redis。需要托管账户与同步时,在 Cloudflare 侧配置 `AUTH_SECRET``ADMIN_PASSWORD` / `ACCOUNTS`,以及 `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN`
### Android TV APK
仓库内提供轻量 Android TV WebView 壳工程,位于 `android-tv/`
@@ -460,7 +487,7 @@ cd android-tv && ./gradlew --no-daemon lint test assembleDebug assembleRelease
## 版本与更新
- 当前版本:[`4.9.3`](CHANGELOG.md)
- 当前版本:[`4.9.4`](CHANGELOG.md)
- 更新日志:[`CHANGELOG.md`](CHANGELOG.md)
- 应用内“版本与更新”卡片使用仓库内的 `app-release.json` 元数据
+14 -1
View File
@@ -4,8 +4,21 @@
"name": "KVideo",
"branch": "main"
},
"currentVersion": "4.9.3",
"currentVersion": "4.9.4",
"releases": [
{
"version": "4.9.4",
"publishedAt": "2026-05-02",
"title": "Docker Redis 托管账户支持",
"notes": [
"Docker Compose 新增持久化 Redis 服务,默认通过 REDIS_URL=redis://redis:6379/0 启用自托管托管账户存储。",
"服务端 Redis 层新增普通 redis:// / rediss:// TCP Redis 支持;Cloudflare / Vercel 等受限托管环境仍使用 Upstash REST 凭据。",
"新增 .env.example,补齐 Docker 托管账户、Redis、Upstash 和中继相关环境变量说明。",
"会话 Cookie 的 Secure 标记改为按请求协议 / x-forwarded-proto 自动决定,并新增 AUTH_COOKIE_SECURE 强制开关,避免 HTTP Docker 直连登录后浏览器丢弃会话。",
"README、Cloudflare 注释和部署文档补齐 Docker Redis 与 Cloudflare Upstash 的边界说明。",
"新增 TCP Redis 客户端回归测试,覆盖 JSON 存取、计数器、TTL、删除和缺失键行为。"
]
},
{
"version": "4.9.3",
"publishedAt": "2026-04-15",
+1 -1
View File
@@ -66,7 +66,7 @@ export async function POST(request: NextRequest) {
}
await clearAuthFailures(request, username, type);
return createLoginResponse(session);
return createLoginResponse(session, request);
} catch {
return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 });
}
+28
View File
@@ -13,9 +13,37 @@ services:
environment:
NODE_ENV: production
PORT: 3000
AUTH_SECRET: ${AUTH_SECRET:-}
AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ACCESS_PASSWORD: ${ACCESS_PASSWORD:-}
ACCOUNTS: ${ACCOUNTS:-}
PREMIUM_PASSWORD: ${PREMIUM_PASSWORD:-}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
UPSTASH_REDIS_REST_URL: ${UPSTASH_REDIS_REST_URL:-}
UPSTASH_REDIS_REST_TOKEN: ${UPSTASH_REDIS_REST_TOKEN:-}
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget --spider --quiet http://127.0.0.1:3000/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
redis:
image: redis:7-alpine
container_name: kvideo-redis
restart: always
command: ["redis-server", "--appendonly", "yes"]
volumes:
- kvideo-redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
volumes:
kvideo-redis-data:
+25 -4
View File
@@ -75,6 +75,7 @@ const ACCOUNTS = process.env.ACCOUNTS || '';
const AUTH_SECRET = process.env.AUTH_SECRET?.trim() || '';
const PREMIUM_PASSWORD = process.env.PREMIUM_PASSWORD || '';
const PERSIST_SESSION = process.env.PERSIST_SESSION !== 'false';
const AUTH_COOKIE_SECURE = process.env.AUTH_COOKIE_SECURE?.trim().toLowerCase() || '';
const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || '';
const IPTV_SOURCES = process.env.IPTV_SOURCES || process.env.NEXT_PUBLIC_IPTV_SOURCES || '';
const MERGE_SOURCES = process.env.MERGE_SOURCES || process.env.NEXT_PUBLIC_MERGE_SOURCES || '';
@@ -255,6 +256,26 @@ function sessionPayloadToServerSession(payload: SessionPayload): ServerAuthSessi
};
}
function shouldUseSecureCookie(request?: NextRequest): boolean {
if (AUTH_COOKIE_SECURE === 'true') return true;
if (AUTH_COOKIE_SECURE === 'false') return false;
const forwardedProto = request?.headers.get('x-forwarded-proto')
?.split(',')[0]
?.trim()
.toLowerCase();
if (forwardedProto) {
return forwardedProto === 'https';
}
if (request) {
return request.nextUrl.protocol === 'https:';
}
return process.env.NODE_ENV === 'production';
}
export function toPublicSession(session: ServerAuthSession): PublicSessionData {
return {
accountId: session.accountId,
@@ -302,11 +323,11 @@ export async function getServerSession(request: NextRequest): Promise<ServerAuth
return sessionPayloadToServerSession(payload);
}
function applySessionCookie(response: NextResponse, token: string, persist: boolean): void {
function applySessionCookie(response: NextResponse, token: string, persist: boolean, request?: NextRequest): void {
response.cookies.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
secure: shouldUseSecureCookie(request),
path: '/',
...(persist ? { maxAge: SESSION_MAX_AGE_SECONDS } : {}),
});
@@ -440,7 +461,7 @@ export async function validatePremiumAccess(
return authenticateLegacyAdminCredential(body.password);
}
export async function createLoginResponse(session: ServerAuthSession): Promise<NextResponse> {
export async function createLoginResponse(session: ServerAuthSession, request?: NextRequest): Promise<NextResponse> {
const config = await getPublicAuthConfig();
if (config.authError) {
return NextResponse.json({ valid: false, message: config.authError }, { status: 503 });
@@ -457,7 +478,7 @@ export async function createLoginResponse(session: ServerAuthSession): Promise<N
...config,
});
applySessionCookie(response, token, PERSIST_SESSION);
applySessionCookie(response, token, PERSIST_SESSION, request);
return response;
}
+280 -5
View File
@@ -2,18 +2,293 @@ import 'server-only';
import { Redis } from '@upstash/redis';
let cachedRedis: Redis | null | undefined;
type RedisPrimitive = string | number | null;
type RedisRawValue = RedisPrimitive | RedisRawValue[];
export function getRedisClient(): Redis | null {
export interface RedisClient {
get<T = unknown>(key: string): Promise<T | null>;
set(key: string, value: unknown): Promise<unknown>;
del(key: string): Promise<unknown>;
incr(key: string): Promise<number>;
expire(key: string, seconds: number): Promise<unknown>;
ttl(key: string): Promise<number>;
}
class RedisProtocolError extends Error {
constructor(message: string) {
super(message);
this.name = 'RedisProtocolError';
}
}
class TcpRedisClient implements RedisClient {
private readonly url: URL;
constructor(redisUrl: string) {
this.url = new URL(redisUrl);
}
async get<T = unknown>(key: string): Promise<T | null> {
const value = await this.command(['GET', key]);
if (value === null) return null;
if (typeof value !== 'string') return value as T;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
}
async set(key: string, value: unknown): Promise<unknown> {
return this.command(['SET', key, JSON.stringify(value)]);
}
async del(key: string): Promise<unknown> {
return this.command(['DEL', key]);
}
async incr(key: string): Promise<number> {
const value = await this.command(['INCR', key]);
return typeof value === 'number' ? value : Number(value);
}
async expire(key: string, seconds: number): Promise<unknown> {
return this.command(['EXPIRE', key, String(seconds)]);
}
async ttl(key: string): Promise<number> {
const value = await this.command(['TTL', key]);
return typeof value === 'number' ? value : Number(value);
}
private async command(args: string[]): Promise<RedisRawValue> {
const setupCommands: string[][] = [];
const username = decodeUrlPart(this.url.username);
const password = decodeUrlPart(this.url.password);
const database = this.url.pathname.replace(/^\//, '');
if (password) {
setupCommands.push(username ? ['AUTH', username, password] : ['AUTH', password]);
}
if (database) {
setupCommands.push(['SELECT', database]);
}
const responses = await sendRedisCommands(this.url, [
...setupCommands,
args,
['QUIT'],
]);
return responses[setupCommands.length] ?? null;
}
}
let cachedRedis: RedisClient | null | undefined;
function decodeUrlPart(value: string): string {
if (!value) return '';
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isManagedPlatform(): boolean {
return process.env.VERCEL === '1' ||
Boolean(process.env.VERCEL_ENV) ||
process.env.CF_PAGES === '1' ||
Boolean(process.env.CF_PAGES_URL) ||
Boolean(process.env.CLOUDFLARE_ACCOUNT_ID) ||
Boolean(process.env.CF_ACCOUNT_ID) ||
Boolean(process.env.WORKERS_CI);
}
function getTcpRedisUrl(): string {
if (isManagedPlatform()) return '';
return (
process.env.KVIDEO_REDIS_URL?.trim() ||
process.env.REDIS_URL?.trim() ||
''
);
}
function encodeCommand(args: string[]): Buffer {
const parts = [`*${args.length}\r\n`];
for (const arg of args) {
parts.push(`$${Buffer.byteLength(arg)}\r\n${arg}\r\n`);
}
return Buffer.from(parts.join(''));
}
function readLine(buffer: Buffer, offset: number): { line: string; offset: number } | null {
const end = buffer.indexOf('\r\n', offset);
if (end === -1) return null;
return {
line: buffer.toString('utf8', offset, end),
offset: end + 2,
};
}
function parseResp(buffer: Buffer, offset: number = 0): { value: RedisRawValue; offset: number } | null {
if (offset >= buffer.length) return null;
const prefix = String.fromCharCode(buffer[offset]);
const line = readLine(buffer, offset + 1);
if (!line) return null;
switch (prefix) {
case '+':
return { value: line.line, offset: line.offset };
case '-':
throw new RedisProtocolError(line.line);
case ':':
return { value: Number(line.line), offset: line.offset };
case '$': {
const length = Number(line.line);
if (length === -1) return { value: null, offset: line.offset };
const valueStart = line.offset;
const valueEnd = valueStart + length;
const nextOffset = valueEnd + 2;
if (nextOffset > buffer.length) return null;
return {
value: buffer.toString('utf8', valueStart, valueEnd),
offset: nextOffset,
};
}
case '*': {
const length = Number(line.line);
if (length === -1) return { value: null, offset: line.offset };
const values: RedisRawValue[] = [];
let cursor = line.offset;
for (let index = 0; index < length; index += 1) {
const parsed = parseResp(buffer, cursor);
if (!parsed) return null;
values.push(parsed.value);
cursor = parsed.offset;
}
return { value: values, offset: cursor };
}
default:
throw new RedisProtocolError(`Unsupported Redis response prefix: ${prefix}`);
}
}
async function sendRedisCommands(redisUrl: URL, commands: string[][]): Promise<RedisRawValue[]> {
const useTls = redisUrl.protocol === 'rediss:';
const host = redisUrl.hostname || '127.0.0.1';
const port = Number(redisUrl.port || (useTls ? 6380 : 6379));
const timeout = Number(process.env.KVIDEO_REDIS_CONNECT_TIMEOUT_MS || 5000);
const payload = Buffer.concat(commands.map(encodeCommand));
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback: () => void): void => {
if (settled) return;
settled = true;
callback();
};
const onReady = (): void => {
socket.write(payload);
};
let socket: import('node:net').Socket;
if (useTls) {
import('node:tls')
.then(({ connect }) => {
socket = connect({ host, port, servername: host }, onReady);
bindSocketHandlers(socket);
})
.catch((error: unknown) => finish(() => reject(error)));
} else {
import('node:net')
.then(({ connect }) => {
socket = connect({ host, port }, onReady);
bindSocketHandlers(socket);
})
.catch((error: unknown) => finish(() => reject(error)));
}
function bindSocketHandlers(activeSocket: import('node:net').Socket): void {
activeSocket.setTimeout(timeout);
activeSocket.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
activeSocket.on('timeout', () => {
activeSocket.destroy(new Error(`Redis command timed out after ${timeout}ms`));
});
activeSocket.on('error', (error) => {
finish(() => reject(error));
});
activeSocket.on('close', (hadError) => {
if (hadError) return;
finish(() => {
try {
const buffer = Buffer.concat(chunks);
const responses: RedisRawValue[] = [];
let offset = 0;
while (offset < buffer.length) {
const parsed = parseResp(buffer, offset);
if (!parsed) {
throw new RedisProtocolError('Incomplete Redis response');
}
responses.push(parsed.value);
offset = parsed.offset;
}
resolve(responses);
} catch (error) {
reject(error);
}
});
});
}
});
}
export function getRedisClient(): RedisClient | null {
if (cachedRedis !== undefined) {
return cachedRedis;
}
if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) {
cachedRedis = null;
if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) {
cachedRedis = Redis.fromEnv() as RedisClient;
return cachedRedis;
}
cachedRedis = Redis.fromEnv();
const tcpRedisUrl = getTcpRedisUrl();
if (tcpRedisUrl) {
cachedRedis = new TcpRedisClient(tcpRedisUrl);
return cachedRedis;
}
cachedRedis = null;
return cachedRedis;
}
export function resetRedisClientForTests(): void {
cachedRedis = undefined;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kvideo",
"version": "4.9.3",
"version": "4.9.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "4.9.3",
"version": "4.9.4",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.9.3",
"version": "4.9.4",
"private": true,
"scripts": {
"dev": "next dev --port ${PORT:-3000}",
+150
View File
@@ -0,0 +1,150 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer, type Socket } from 'node:net';
import {
getRedisClient,
resetRedisClientForTests,
} from '@/lib/server/redis-client';
function encodeSimple(value: string): string {
return `+${value}\r\n`;
}
function encodeInteger(value: number): string {
return `:${value}\r\n`;
}
function encodeBulk(value: string | null): string {
if (value === null) return '$-1\r\n';
return `$${Buffer.byteLength(value)}\r\n${value}\r\n`;
}
function parseCommands(buffer: Buffer): string[][] {
const commands: string[][] = [];
let offset = 0;
while (offset < buffer.length) {
assert.equal(String.fromCharCode(buffer[offset]), '*');
const commandLineEnd = buffer.indexOf('\r\n', offset);
const itemCount = Number(buffer.toString('utf8', offset + 1, commandLineEnd));
offset = commandLineEnd + 2;
const command: string[] = [];
for (let index = 0; index < itemCount; index += 1) {
assert.equal(String.fromCharCode(buffer[offset]), '$');
const lengthLineEnd = buffer.indexOf('\r\n', offset);
const length = Number(buffer.toString('utf8', offset + 1, lengthLineEnd));
const valueStart = lengthLineEnd + 2;
const valueEnd = valueStart + length;
command.push(buffer.toString('utf8', valueStart, valueEnd));
offset = valueEnd + 2;
}
commands.push(command);
}
return commands;
}
async function withFakeRedis(
callback: (url: string, seenCommands: string[][]) => Promise<void>,
): Promise<void> {
const data = new Map<string, string>();
const seenCommands: string[][] = [];
const server = createServer((socket: Socket) => {
socket.on('data', (chunk) => {
for (const command of parseCommands(chunk)) {
seenCommands.push(command);
const [name, ...args] = command;
switch (name.toUpperCase()) {
case 'AUTH':
case 'SELECT':
socket.write(encodeSimple('OK'));
break;
case 'SET':
data.set(args[0], args[1]);
socket.write(encodeSimple('OK'));
break;
case 'GET':
socket.write(encodeBulk(data.get(args[0]) ?? null));
break;
case 'INCR': {
const next = Number(data.get(args[0]) || 0) + 1;
data.set(args[0], String(next));
socket.write(encodeInteger(next));
break;
}
case 'EXPIRE':
socket.write(encodeInteger(data.has(args[0]) ? 1 : 0));
break;
case 'TTL':
socket.write(encodeInteger(data.has(args[0]) ? -1 : -2));
break;
case 'DEL': {
const deleted = data.delete(args[0]);
socket.write(encodeInteger(deleted ? 1 : 0));
break;
}
case 'QUIT':
socket.write(encodeSimple('OK'));
socket.end();
break;
default:
socket.write(`-ERR unsupported command ${name}\r\n`);
}
}
});
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
assert.ok(address && typeof address === 'object');
try {
await callback(`redis://:[email protected]:${address.port}/1`, seenCommands);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
}
test('TCP Redis client supports Docker-style redis:// storage', async () => {
await withFakeRedis(async (url, seenCommands) => {
delete process.env.UPSTASH_REDIS_REST_URL;
delete process.env.UPSTASH_REDIS_REST_TOKEN;
process.env.REDIS_URL = url;
resetRedisClientForTests();
const redis = getRedisClient();
assert.ok(redis);
await redis.set('account', { username: 'admin', role: 'super_admin' });
assert.deepEqual(await redis.get('account'), {
username: 'admin',
role: 'super_admin',
});
assert.equal(await redis.incr('auth:throttle:test'), 1);
assert.equal(await redis.ttl('auth:throttle:test'), -1);
assert.equal(await redis.del('auth:throttle:test'), 1);
assert.equal(await redis.get('missing'), null);
assert.deepEqual(seenCommands.slice(0, 3), [
['AUTH', 'secret'],
['SELECT', '1'],
['SET', 'account', '{"username":"admin","role":"super_admin"}'],
]);
});
delete process.env.REDIS_URL;
resetRedisClientForTests();
});
+2
View File
@@ -6,6 +6,8 @@ import { signSessionPayload } from '@/lib/server/auth-helpers';
process.env.AUTH_SECRET = 'route-test-secret';
delete process.env.UPSTASH_REDIS_REST_URL;
delete process.env.UPSTASH_REDIS_REST_TOKEN;
delete process.env.KVIDEO_REDIS_URL;
delete process.env.REDIS_URL;
async function createSessionCookie(): Promise<string> {
return signSessionPayload({
+6
View File
@@ -3,6 +3,12 @@ main = ".open-next/worker.js"
compatibility_date = "2026-04-16"
compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"]
# Cloudflare Workers cannot use TCP Redis URLs from Docker Compose.
# Configure AUTH_SECRET plus Upstash REST secrets for managed accounts:
# wrangler secret put AUTH_SECRET
# wrangler secret put UPSTASH_REDIS_REST_URL
# wrangler secret put UPSTASH_REDIS_REST_TOKEN
[assets]
directory = ".open-next/assets"
binding = "ASSETS"