mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 08:13:43 +08:00
feat(docker): support redis managed accounts
This commit is contained in:
@@ -3,6 +3,9 @@ Dockerfile
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.next
|
||||
.open-next
|
||||
.vercel
|
||||
|
||||
@@ -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=
|
||||
@@ -36,6 +36,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -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、权限编辑和密码重置。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[](https://github.com/KuekHaoYang/KVideo/actions/workflows/Github_Upstream_Sync.yml)
|
||||
[](https://deepwiki.com/KuekHaoYang/KVideo)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://react.dev/)
|
||||
[](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
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.9.3",
|
||||
"version": "4.9.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port ${PORT:-3000}",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user