From ffabdbe0e44b765c70655bebd844d92327a3f9e6 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sat, 2 May 2026 15:03:03 +0800 Subject: [PATCH] feat(docker): support redis managed accounts --- .dockerignore | 3 + .env.example | 25 +++ .gitignore | 1 + CHANGELOG.md | 9 + README.md | 55 ++++-- app-release.json | 15 +- app/api/auth/route.ts | 2 +- docker-compose.yml | 28 +++ lib/server/auth.ts | 29 ++- lib/server/redis-client.ts | 285 +++++++++++++++++++++++++++- package-lock.json | 4 +- package.json | 2 +- tests/redis-client.test.ts | 150 +++++++++++++++ tests/redis-fallback-routes.test.ts | 2 + wrangler.toml | 6 + 15 files changed, 588 insertions(+), 28 deletions(-) create mode 100644 .env.example create mode 100644 tests/redis-client.test.ts diff --git a/.dockerignore b/.dockerignore index 255730c..dda2e3b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,9 @@ Dockerfile node_modules npm-debug.log README.md +.env +.env.* +!.env.example .next .open-next .vercel diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2c45b35 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 4ae9769..67525c3 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f0447..41d646a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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、权限编辑和密码重置。 diff --git a/README.md b/README.md index 142211c..15a0910 100644 --- a/README.md +++ b/README.md @@ -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` 元数据 diff --git a/app-release.json b/app-release.json index e9c9899..f2d0dfd 100644 --- a/app-release.json +++ b/app-release.json @@ -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", diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index 9888fde..cbf984b 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -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 }); } diff --git a/docker-compose.yml b/docker-compose.yml index ce28a35..dc223e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/lib/server/auth.ts b/lib/server/auth.ts index c1075b6..bd1501a 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -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 { +export async function createLoginResponse(session: ServerAuthSession, request?: NextRequest): Promise { 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(key: string): Promise; + set(key: string, value: unknown): Promise; + del(key: string): Promise; + incr(key: string): Promise; + expire(key: string, seconds: number): Promise; + ttl(key: string): Promise; +} + +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(key: string): Promise { + 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 { + return this.command(['SET', key, JSON.stringify(value)]); + } + + async del(key: string): Promise { + return this.command(['DEL', key]); + } + + async incr(key: string): Promise { + const value = await this.command(['INCR', key]); + return typeof value === 'number' ? value : Number(value); + } + + async expire(key: string, seconds: number): Promise { + return this.command(['EXPIRE', key, String(seconds)]); + } + + async ttl(key: string): Promise { + const value = await this.command(['TTL', key]); + return typeof value === 'number' ? value : Number(value); + } + + private async command(args: string[]): Promise { + 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 { + 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; +} diff --git a/package-lock.json b/package-lock.json index a208f72..f6f57d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 47179aa..a88ad5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.9.3", + "version": "4.9.4", "private": true, "scripts": { "dev": "next dev --port ${PORT:-3000}", diff --git a/tests/redis-client.test.ts b/tests/redis-client.test.ts new file mode 100644 index 0000000..4a0c230 --- /dev/null +++ b/tests/redis-client.test.ts @@ -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, +): Promise { + const data = new Map(); + 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((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + assert.ok(address && typeof address === 'object'); + + try { + await callback(`redis://:secret@127.0.0.1:${address.port}/1`, seenCommands); + } finally { + await new Promise((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(); +}); diff --git a/tests/redis-fallback-routes.test.ts b/tests/redis-fallback-routes.test.ts index 1441c91..a297e36 100644 --- a/tests/redis-fallback-routes.test.ts +++ b/tests/redis-fallback-routes.test.ts @@ -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 { return signSessionPayload({ diff --git a/wrangler.toml b/wrangler.toml index ae2140b..f9c4e17 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -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"