From 27d30139c1e384c21ee31d413c698f0c85418961 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 20 Jul 2026 14:45:31 +0800 Subject: [PATCH 1/8] fix: support Upstash Redis on Cloudflare Pages --- app/api/auth/route.ts | 21 +++++++++++++++++++-- app/api/auth/session/route.ts | 18 ++++++++++++++++-- app/api/user/config/route.ts | 2 +- app/api/user/sync/route.ts | 2 +- components/PasswordGate.tsx | 6 ++++++ lib/server/auth.ts | 26 +++++++++++++++++++++----- 6 files changed, 64 insertions(+), 11 deletions(-) diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index 4fdc364..837921f 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -3,13 +3,24 @@ import { authenticateLogin, createLoginResponse, getPublicAuthConfig, + ManagedAuthStorageError, validatePremiumAccess, } from '@/lib/server/auth'; export const runtime = 'edge'; export async function GET() { - return NextResponse.json(await getPublicAuthConfig()); + try { + return NextResponse.json(await getPublicAuthConfig()); + } catch (error) { + if (error instanceof ManagedAuthStorageError) { + return NextResponse.json( + { error: 'Managed authentication storage is unavailable' }, + { status: 503 }, + ); + } + throw error; + } } export async function POST(request: NextRequest) { @@ -32,7 +43,13 @@ export async function POST(request: NextRequest) { } return createLoginResponse(session, request); - } catch { + } catch (error) { + if (error instanceof ManagedAuthStorageError) { + return NextResponse.json( + { valid: false, message: 'Managed authentication storage is unavailable' }, + { status: 503 }, + ); + } return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 }); } } diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 2463982..0ab58af 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -1,10 +1,24 @@ import { NextRequest } from 'next/server'; -import { createSessionStatusResponse, logoutResponse } from '@/lib/server/auth'; +import { + createSessionStatusResponse, + logoutResponse, + ManagedAuthStorageError, +} from '@/lib/server/auth'; export const runtime = 'edge'; export async function GET(request: NextRequest) { - return createSessionStatusResponse(request); + try { + return await createSessionStatusResponse(request); + } catch (error) { + if (error instanceof ManagedAuthStorageError) { + return Response.json( + { authenticated: false, error: 'Managed authentication storage is unavailable' }, + { status: 503 }, + ); + } + throw error; + } } export async function DELETE(request: NextRequest) { diff --git a/app/api/user/config/route.ts b/app/api/user/config/route.ts index 3195d41..e980c6a 100644 --- a/app/api/user/config/route.ts +++ b/app/api/user/config/route.ts @@ -5,7 +5,7 @@ * so they persist across browsers, devices, and PWA installs. */ -import { Redis } from '@upstash/redis'; +import { Redis } from '@upstash/redis/cloudflare'; import { NextRequest, NextResponse } from 'next/server'; import { authenticationRequiredResponse } from '@/lib/server/api-responses'; import { getServerSession } from '@/lib/server/auth'; diff --git a/app/api/user/sync/route.ts b/app/api/user/sync/route.ts index cd190d6..e52aee3 100644 --- a/app/api/user/sync/route.ts +++ b/app/api/user/sync/route.ts @@ -1,4 +1,4 @@ -import { Redis } from '@upstash/redis'; +import { Redis } from '@upstash/redis/cloudflare'; import { NextRequest, NextResponse } from 'next/server'; import { authenticationRequiredResponse } from '@/lib/server/api-responses'; import { getServerSession } from '@/lib/server/auth'; diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx index d97d059..45fd718 100644 --- a/components/PasswordGate.tsx +++ b/components/PasswordGate.tsx @@ -207,6 +207,12 @@ export function PasswordGate({ }); const data = await response.json(); + if (response.status === 503) { + setError('认证存储暂时不可用,请检查 Upstash Redis 配置'); + setIsValidating(false); + return; + } + if (data.valid && data.session) { setSession(toAuthSession(data.session), data.persistSession ?? persistSession); setIsLocked(false); diff --git a/lib/server/auth.ts b/lib/server/auth.ts index 2a71490..4b36df1 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -1,4 +1,4 @@ -import { Redis } from '@upstash/redis'; +import { Redis } from '@upstash/redis/cloudflare'; import { NextRequest, NextResponse } from 'next/server'; import { getRuntimeFeatures } from '@/lib/server/runtime-features'; import { @@ -89,6 +89,13 @@ const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD; let cachedRedis: Redis | null | undefined; +export class ManagedAuthStorageError extends Error { + constructor(operation: 'read' | 'write', cause?: unknown) { + super(`Managed auth storage ${operation} failed`, { cause }); + this.name = 'ManagedAuthStorageError'; + } +} + function getRedisClient(): Redis | null { if (cachedRedis !== undefined) { return cachedRedis; @@ -99,7 +106,10 @@ function getRedisClient(): Redis | null { return cachedRedis; } - cachedRedis = Redis.fromEnv(); + cachedRedis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL, + token: process.env.UPSTASH_REDIS_REST_TOKEN, + }); return cachedRedis; } @@ -140,8 +150,9 @@ async function readManagedAccounts(): Promise { const stored = await redis.get(MANAGED_ACCOUNTS_KEY); if (!Array.isArray(stored)) return []; return stored.filter(isStoredAccountRecord).map(normalizeStoredAccount); - } catch { - return []; + } catch (error) { + console.error('Managed auth Redis read failed:', error); + throw new ManagedAuthStorageError('read', error); } } @@ -151,7 +162,12 @@ async function saveManagedAccounts(accounts: StoredAccountRecord[]): Promise Date: Mon, 20 Jul 2026 15:16:00 +0800 Subject: [PATCH 2/8] fix: sync managed admin password from environment --- lib/server/auth-helpers.ts | 8 ++++++++ lib/server/auth.ts | 39 ++++++++++++++++++++++++++++++++++---- tests/auth.test.ts | 8 ++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/lib/server/auth-helpers.ts b/lib/server/auth-helpers.ts index 4bcd687..9a2ded9 100644 --- a/lib/server/auth-helpers.ts +++ b/lib/server/auth-helpers.ts @@ -177,6 +177,14 @@ export async function verifyPassword(password: string, salt: string, expectedHas return actual.hash === expectedHash; } +export function isBootstrapAdminCredential( + username: string, + password: string, + adminPassword: string +): boolean { + return normalizeUsername(username) === 'admin' && !!adminPassword && password === adminPassword; +} + export async function signSessionPayload(payload: SessionPayload, secret: string): Promise { const payloadBytes = encodeText(JSON.stringify(payload)); const encodedPayload = encodeBase64Url(payloadBytes); diff --git a/lib/server/auth.ts b/lib/server/auth.ts index 4b36df1..e7004d3 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -5,6 +5,7 @@ import { createStoredAccount, ensureUniqueUsername, hashPassword, + isBootstrapAdminCredential, normalizeUsername, parseBootstrapAccounts, resolveLoginMode, @@ -350,11 +351,41 @@ async function authenticateManagedLogin(username: string, password: string): Pro if (!normalizedUsername || !password) return null; const accounts = await ensureManagedAccountsBootstrapped(); - const account = accounts.find((item) => item.username === normalizedUsername); - if (!account) return null; + let account = accounts.find((item) => item.username === normalizedUsername); + const usesBootstrapAdminCredential = isBootstrapAdminCredential( + normalizedUsername, + password, + effectiveAdminPassword + ); - const valid = await verifyPassword(password, account.passwordSalt, account.passwordHash); - if (!valid) return null; + if (!account) { + if (!usesBootstrapAdminCredential) return null; + + account = await createStoredAccount({ + username: 'admin', + password, + name: '超级管理员', + role: 'super_admin', + customPermissions: [], + }); + await saveManagedAccounts([...accounts, account]); + } else { + const valid = await verifyPassword(password, account.passwordSalt, account.passwordHash); + if (!valid) { + if (!usesBootstrapAdminCredential) return null; + + const nextPassword = await hashPassword(password); + account = { + ...account, + passwordHash: nextPassword.hash, + passwordSalt: nextPassword.salt, + updatedAt: Date.now(), + }; + await saveManagedAccounts( + accounts.map((item) => item.id === account?.id ? account : item) + ); + } + } return { accountId: account.id, diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 983d15c..65be611 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createStoredAccount, hashPassword, + isBootstrapAdminCredential, parseBootstrapAccounts, resolveLoginMode, shouldUseSecureSessionCookie, @@ -73,6 +74,13 @@ test('hashPassword and verifyPassword round-trip correctly', async () => { assert.equal(await verifyPassword('wrong-password', password.salt, password.hash), false); }); +test('bootstrap admin credential only accepts the configured admin password', () => { + assert.equal(isBootstrapAdminCredential('ADMIN', 'current-secret', 'current-secret'), true); + assert.equal(isBootstrapAdminCredential('admin', 'old-secret', 'current-secret'), false); + assert.equal(isBootstrapAdminCredential('viewer', 'current-secret', 'current-secret'), false); + assert.equal(isBootstrapAdminCredential('admin', '', ''), false); +}); + test('signSessionPayload and verifySessionToken reject tampering', async () => { const token = await signSessionPayload({ accountId: 'account-1', From 2a59e31cd4f517a2b95006e5cf5a8f79ad6d1c68 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 20 Jul 2026 15:28:05 +0800 Subject: [PATCH 3/8] fix: read admin password at runtime on Cloudflare --- lib/server/auth.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/server/auth.ts b/lib/server/auth.ts index e7004d3..8458e2d 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -86,7 +86,9 @@ const DANMAKU_API_URL = process.env.DANMAKU_API_URL || process.env.NEXT_PUBLIC_D const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const MANAGED_AUTH_FORCED = process.env.MANAGED_AUTH_ENABLED === 'true'; -const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD; +function getEffectiveAdminPassword(): string { + return process.env.ADMIN_PASSWORD || process.env.ACCESS_PASSWORD || ADMIN_PASSWORD || ACCESS_PASSWORD; +} let cachedRedis: Redis | null | undefined; @@ -119,7 +121,7 @@ function isManagedAuthEnabled(): boolean { } function isLegacyAuthConfigured(): boolean { - return !!(effectiveAdminPassword || ACCOUNTS); + return !!(getEffectiveAdminPassword() || ACCOUNTS); } function isStoredAccountRecord(value: unknown): value is StoredAccountRecord { @@ -174,6 +176,7 @@ async function saveManagedAccounts(accounts: StoredAccountRecord[]): Promise(); + const effectiveAdminPassword = getEffectiveAdminPassword(); if (effectiveAdminPassword) { usernames.add('admin'); @@ -274,7 +277,7 @@ function resolveSessionSecret(loginMode: LoginMode): string | null { } if (loginMode === 'legacy_password' && isLegacyAuthConfigured()) { - return `legacy:${effectiveAdminPassword}:${ACCOUNTS}:${PREMIUM_PASSWORD}`; + return `legacy:${getEffectiveAdminPassword()}:${ACCOUNTS}:${PREMIUM_PASSWORD}`; } return null; @@ -355,7 +358,7 @@ async function authenticateManagedLogin(username: string, password: string): Pro const usesBootstrapAdminCredential = isBootstrapAdminCredential( normalizedUsername, password, - effectiveAdminPassword + getEffectiveAdminPassword() ); if (!account) { @@ -401,6 +404,7 @@ async function authenticateManagedLogin(username: string, password: string): Pro async function authenticateLegacyLogin(password: string): Promise { if (!password) return null; + const effectiveAdminPassword = getEffectiveAdminPassword(); if (effectiveAdminPassword && password === effectiveAdminPassword) { return { @@ -553,7 +557,7 @@ export async function listAccountInfo(): Promise { const legacyAccounts: AccountInfo[] = []; let index = 0; - if (effectiveAdminPassword) { + if (getEffectiveAdminPassword()) { legacyAccounts.push({ id: 'legacy-admin', username: 'admin', From b7e962b031f64b3fb25ff80dbbfdd79a88d61a66 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 20 Jul 2026 15:38:47 +0800 Subject: [PATCH 4/8] fix: read Cloudflare request environment --- lib/server/auth.ts | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/lib/server/auth.ts b/lib/server/auth.ts index 8458e2d..212cee4 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -1,4 +1,5 @@ import { Redis } from '@upstash/redis/cloudflare'; +import { getOptionalRequestContext } from '@cloudflare/next-on-pages'; import { NextRequest, NextResponse } from 'next/server'; import { getRuntimeFeatures } from '@/lib/server/runtime-features'; import { @@ -86,8 +87,21 @@ const DANMAKU_API_URL = process.env.DANMAKU_API_URL || process.env.NEXT_PUBLIC_D const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const MANAGED_AUTH_FORCED = process.env.MANAGED_AUTH_ENABLED === 'true'; +function getRuntimeEnvValue(name: string, fallback = ''): string { + try { + const runtimeEnv = getOptionalRequestContext()?.env as unknown as Record | undefined; + const value = runtimeEnv?.[name]; + if (typeof value === 'string') return value; + } catch { + // Outside Cloudflare's request runtime, fall back to process.env. + } + + return process.env[name] || fallback; +} + function getEffectiveAdminPassword(): string { - return process.env.ADMIN_PASSWORD || process.env.ACCESS_PASSWORD || ADMIN_PASSWORD || ACCESS_PASSWORD; + return getRuntimeEnvValue('ADMIN_PASSWORD', ADMIN_PASSWORD) || + getRuntimeEnvValue('ACCESS_PASSWORD', ACCESS_PASSWORD); } let cachedRedis: Redis | null | undefined; @@ -104,20 +118,22 @@ function getRedisClient(): Redis | null { return cachedRedis; } - if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) { + const url = getRuntimeEnvValue('UPSTASH_REDIS_REST_URL'); + const token = getRuntimeEnvValue('UPSTASH_REDIS_REST_TOKEN'); + if (!url || !token) { cachedRedis = null; return cachedRedis; } cachedRedis = new Redis({ - url: process.env.UPSTASH_REDIS_REST_URL, - token: process.env.UPSTASH_REDIS_REST_TOKEN, + url, + token, }); return cachedRedis; } function isManagedAuthEnabled(): boolean { - return !!AUTH_SECRET && !!getRedisClient(); + return !!getRuntimeEnvValue('AUTH_SECRET', AUTH_SECRET) && !!getRedisClient(); } function isLegacyAuthConfigured(): boolean { @@ -246,7 +262,7 @@ export async function getPublicAuthConfig(): Promise { const loginMode = resolveLoginMode({ managedAccountCount, managedAuthEnabled, - managedAuthForced: MANAGED_AUTH_FORCED, + managedAuthForced: getRuntimeEnvValue('MANAGED_AUTH_ENABLED') === 'true' || MANAGED_AUTH_FORCED, legacyAuthConfigured: isLegacyAuthConfigured(), }); @@ -272,8 +288,9 @@ async function generateLegacyProfileId(password: string): Promise { } function resolveSessionSecret(loginMode: LoginMode): string | null { - if (AUTH_SECRET) { - return AUTH_SECRET; + const authSecret = getRuntimeEnvValue('AUTH_SECRET', AUTH_SECRET); + if (authSecret) { + return authSecret; } if (loginMode === 'legacy_password' && isLegacyAuthConfigured()) { From dd4b7c9f24e8a3a6032fecc169f102d93d3ad445 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 20 Jul 2026 15:48:16 +0800 Subject: [PATCH 5/8] fix: authenticate environment admin before Redis hashing --- lib/server/auth.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/server/auth.ts b/lib/server/auth.ts index 212cee4..dcef9ab 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -370,13 +370,26 @@ async function authenticateManagedLogin(username: string, password: string): Pro const normalizedUsername = normalizeUsername(username); if (!normalizedUsername || !password) return null; - const accounts = await ensureManagedAccountsBootstrapped(); - let account = accounts.find((item) => item.username === normalizedUsername); const usesBootstrapAdminCredential = isBootstrapAdminCredential( normalizedUsername, password, getEffectiveAdminPassword() ); + if (usesBootstrapAdminCredential) { + return { + accountId: 'managed-admin-env', + profileId: 'managed-admin-env', + username: 'admin', + name: '超级管理员', + role: 'super_admin', + customPermissions: [], + mode: 'managed', + iat: Date.now(), + }; + } + + const accounts = await ensureManagedAccountsBootstrapped(); + let account = accounts.find((item) => item.username === normalizedUsername); if (!account) { if (!usesBootstrapAdminCredential) return null; From 387da736e1ad28c3f54b5a0cfff3e0c77ed30b69 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 20 Jul 2026 15:58:23 +0800 Subject: [PATCH 6/8] fix: cap PBKDF2 iterations for Cloudflare Workers --- lib/server/auth-helpers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/server/auth-helpers.ts b/lib/server/auth-helpers.ts index 9a2ded9..e153d8f 100644 --- a/lib/server/auth-helpers.ts +++ b/lib/server/auth-helpers.ts @@ -87,7 +87,8 @@ export function shouldUseSecureSessionCookie(request?: SessionCookieProtocolRequ return request.nextUrl.protocol === 'https:'; } -const PBKDF2_ITERATIONS = 120_000; +// Cloudflare Workers rejects PBKDF2 iteration counts above 100,000. +const PBKDF2_ITERATIONS = 100_000; const PBKDF2_KEY_BYTES = 32; const SESSION_TOKEN_VERSION = 'v1'; From a1a3821538e75330954b3f2696c0d34ff55c60f0 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 21 Jul 2026 20:50:34 +0800 Subject: [PATCH 7/8] fix: complete managed admin persistence --- lib/server/auth.ts | 17 ++++------------- package-lock.json | 12 ++++++------ package.json | 2 +- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/lib/server/auth.ts b/lib/server/auth.ts index dcef9ab..375af6a 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -178,7 +178,10 @@ async function readManagedAccounts(): Promise { async function saveManagedAccounts(accounts: StoredAccountRecord[]): Promise { const redis = getRedisClient(); if (!redis) { - throw new Error('Managed auth storage unavailable'); + throw new ManagedAuthStorageError( + 'write', + new Error('Managed auth storage unavailable') + ); } try { @@ -375,18 +378,6 @@ async function authenticateManagedLogin(username: string, password: string): Pro password, getEffectiveAdminPassword() ); - if (usesBootstrapAdminCredential) { - return { - accountId: 'managed-admin-env', - profileId: 'managed-admin-env', - username: 'admin', - name: '超级管理员', - role: 'super_admin', - customPermissions: [], - mode: 'managed', - iat: Date.now(), - }; - } const accounts = await ensureManagedAccountsBootstrapped(); let account = accounts.find((item) => item.username === normalizedUsername); diff --git a/package-lock.json b/package-lock.json index f4dabde..7204543 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.9.13", + "version": "4.9.14", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.9.13", + "version": "4.9.14", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -3300,6 +3300,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3333,7 +3336,7 @@ "arm64" ], "libc": [ - "glibc" + "musl" ], "license": "MIT", "optional": true, @@ -3351,9 +3354,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index e20f61e..5e6728f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.9.13", + "version": "4.9.14", "private": true, "scripts": { "dev": "node scripts/next-with-lan-access.mjs dev", From 2ad9203e39581c2306c81a2b06cdfe2603142c71 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 21 Jul 2026 20:51:05 +0800 Subject: [PATCH 8/8] chore: preserve lockfile platform metadata --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7204543..5a077fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3300,9 +3300,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3336,7 +3333,7 @@ "arm64" ], "libc": [ - "musl" + "glibc" ], "license": "MIT", "optional": true, @@ -3354,6 +3351,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [