diff --git a/app/api/user/config/route.ts b/app/api/user/config/route.ts index e980c6a..eb0a22b 100644 --- a/app/api/user/config/route.ts +++ b/app/api/user/config/route.ts @@ -5,20 +5,25 @@ * so they persist across browsers, devices, and PWA installs. */ -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'; +import { getRedisClient } from '@/lib/server/redis'; export const runtime = 'edge'; -const redis = Redis.fromEnv(); - function redisKey(profileId: string): string { const safe = profileId.replace(/[^a-zA-Z0-9_-]/g, ''); return `user:config:${safe}`; } +function syncUnavailableResponse() { + return NextResponse.json( + { error: 'Server-side sync is not configured on this deployment' }, + { status: 503 } + ); +} + export async function GET(request: NextRequest) { const session = await getServerSession(request); const profileId = session?.profileId; @@ -27,6 +32,11 @@ export async function GET(request: NextRequest) { return authenticationRequiredResponse(); } + const redis = getRedisClient(); + if (!redis) { + return syncUnavailableResponse(); + } + try { const data = await redis.get(redisKey(profileId)); return NextResponse.json({ success: true, data: data || null }); @@ -47,6 +57,11 @@ export async function POST(request: NextRequest) { return authenticationRequiredResponse(); } + const redis = getRedisClient(); + if (!redis) { + return syncUnavailableResponse(); + } + try { const body = await request.json(); const key = redisKey(profileId); diff --git a/app/api/user/sync/route.ts b/app/api/user/sync/route.ts index e52aee3..f83032a 100644 --- a/app/api/user/sync/route.ts +++ b/app/api/user/sync/route.ts @@ -1,21 +1,31 @@ -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'; +import { getRedisClient } from '@/lib/server/redis'; // 确保这行代码在整个文件中只出现一次 export const runtime = 'edge'; -const redis = Redis.fromEnv(); +function syncUnavailableResponse() { + return NextResponse.json( + { error: 'Server-side sync is not configured on this deployment' }, + { status: 503 } + ); +} export async function GET(request: NextRequest) { const session = await getServerSession(request); const profileId = session?.profileId; - + if (!profileId) { return authenticationRequiredResponse(); } + const redis = getRedisClient(); + if (!redis) { + return syncUnavailableResponse(); + } + try { const data = await redis.get(`user:sync:${profileId}`); return NextResponse.json({ @@ -31,11 +41,16 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { const session = await getServerSession(request); const profileId = session?.profileId; - + if (!profileId) { return authenticationRequiredResponse(); } + const redis = getRedisClient(); + if (!redis) { + return syncUnavailableResponse(); + } + try { const body = await request.json(); const { history, favorites } = body; diff --git a/lib/server/auth.ts b/lib/server/auth.ts index 375af6a..d2611da 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -1,6 +1,6 @@ -import { Redis } from '@upstash/redis/cloudflare'; -import { getOptionalRequestContext } from '@cloudflare/next-on-pages'; import { NextRequest, NextResponse } from 'next/server'; +import { getRedisClient } from '@/lib/server/redis'; +import { getRuntimeEnvValue } from '@/lib/server/runtime-env'; import { getRuntimeFeatures } from '@/lib/server/runtime-features'; import { createStoredAccount, @@ -87,25 +87,11 @@ 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 getRuntimeEnvValue('ADMIN_PASSWORD', ADMIN_PASSWORD) || getRuntimeEnvValue('ACCESS_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 }); @@ -113,25 +99,6 @@ export class ManagedAuthStorageError extends Error { } } -function getRedisClient(): Redis | null { - if (cachedRedis !== undefined) { - return cachedRedis; - } - - 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, - token, - }); - return cachedRedis; -} - function isManagedAuthEnabled(): boolean { return !!getRuntimeEnvValue('AUTH_SECRET', AUTH_SECRET) && !!getRedisClient(); } diff --git a/lib/server/redis.ts b/lib/server/redis.ts new file mode 100644 index 0000000..3fabeb6 --- /dev/null +++ b/lib/server/redis.ts @@ -0,0 +1,36 @@ +import { Redis } from '@upstash/redis/cloudflare'; + +import { getRuntimeEnvValue } from '@/lib/server/runtime-env'; + +let cachedRedis: Redis | null | undefined; + +/** + * Build the shared Upstash client from whichever environment source is available. + * + * `Redis.fromEnv()` is deliberately not used here: the `@upstash/redis/cloudflare` + * implementation only reads Cloudflare's global bindings and never falls back to + * `process.env`, so it always resolves to `undefined` on Docker / Node + * self-hosted deployments. The client is also created lazily because Cloudflare's + * per-request bindings are not reachable while a module is being evaluated. + * + * Returns `null` when Upstash is not configured, which callers should treat as + * "server-side sync unavailable" rather than as a request failure. + */ +export function getRedisClient(): Redis | null { + if (cachedRedis !== undefined) { + return cachedRedis; + } + + 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, + token, + }); + return cachedRedis; +} diff --git a/lib/server/runtime-env.ts b/lib/server/runtime-env.ts new file mode 100644 index 0000000..7f08bff --- /dev/null +++ b/lib/server/runtime-env.ts @@ -0,0 +1,20 @@ +import { getOptionalRequestContext } from '@cloudflare/next-on-pages'; + +/** + * Read an environment value that may live in either runtime environment. + * + * On Cloudflare, bindings and secrets are only reachable through the + * per-request context. On Docker / Node self-hosting there is no such context, + * so the value comes from `process.env`. + */ +export 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; +}