@notionx/core 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/rate-limit.js.map +1 -1
- package/dist/auth/routes/google-callback.js.map +1 -1
- package/dist/auth/routes/google.js.map +1 -1
- package/dist/auth/routes/index.js.map +1 -1
- package/dist/auth/routes/verify-email.js.map +1 -1
- package/dist/auth/routes/viewer.js.map +1 -1
- package/dist/auth/turnstile.js.map +1 -1
- package/dist/auth/user-session.d.ts +1 -1
- package/dist/auth/user-session.js.map +1 -1
- package/dist/auth/users.js.map +1 -1
- package/dist/content/index.d.ts +2 -2
- package/dist/content/index.js +8 -60
- package/dist/content/index.js.map +1 -1
- package/dist/content/revalidate.d.ts +2 -1
- package/dist/content/revalidate.js +5 -28
- package/dist/content/revalidate.js.map +1 -1
- package/dist/content/search-index.d.ts +1 -1
- package/dist/content/search-index.js.map +1 -1
- package/dist/content/search.d.ts +2 -5
- package/dist/content/search.js +3 -32
- package/dist/content/search.js.map +1 -1
- package/dist/email/index.js.map +1 -1
- package/dist/{env-C5qu-0R-.d.ts → env-hoez1e-n.d.ts} +0 -4
- package/dist/i18n/index.d.ts +18 -24
- package/dist/i18n/index.js +29 -54
- package/dist/i18n/index.js.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/admin/index.js.map +1 -1
- package/dist/media/index.js +3 -2
- package/dist/media/index.js.map +1 -1
- package/dist/media/routes/index.js +0 -1
- package/dist/media/routes/index.js.map +1 -1
- package/dist/media/routes/notion-media.js +0 -1
- package/dist/media/routes/notion-media.js.map +1 -1
- package/dist/notion/config.d.ts +1 -4
- package/dist/notion/config.js +1 -23
- package/dist/notion/config.js.map +1 -1
- package/dist/notion/content-cache.d.ts +1 -1
- package/dist/notion/generic-source.js +0 -1
- package/dist/notion/generic-source.js.map +1 -1
- package/dist/notion/index.d.ts +3 -3
- package/dist/notion/index.js +1 -23
- package/dist/notion/index.js.map +1 -1
- package/dist/notion/media.d.ts +1 -1
- package/dist/notion/media.js +1 -1
- package/dist/notion/media.js.map +1 -1
- package/dist/notion/routes/index.d.ts +1 -1
- package/dist/notion/routes/index.js +0 -1
- package/dist/notion/routes/index.js.map +1 -1
- package/dist/notion/routes/webhook.d.ts +1 -1
- package/dist/notion/routes/webhook.js +0 -1
- package/dist/notion/routes/webhook.js.map +1 -1
- package/dist/notion/types.d.ts +1 -73
- package/dist/notion/webhook.d.ts +1 -1
- package/dist/notion/webhook.js +0 -1
- package/dist/notion/webhook.js.map +1 -1
- package/dist/pages/index.js +0 -1
- package/dist/pages/index.js.map +1 -1
- package/dist/platform/current.d.ts +1 -1
- package/dist/platform/current.js.map +1 -1
- package/dist/platform/index.d.ts +1 -1
- package/dist/platform/index.js.map +1 -1
- package/dist/platform/runtime.d.ts +1 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/routes/cdn.js.map +1 -1
- package/dist/storage/routes/files.js.map +1 -1
- package/dist/storage/routes/index.js.map +1 -1
- package/dist/util/index.d.ts +1 -1
- package/dist/util/index.js +1 -2
- package/dist/util/index.js.map +1 -1
- package/dist/worker/index.js +0 -1
- package/dist/worker/index.js.map +1 -1
- package/dist/worker/routes/content-revalidate.d.ts +1 -1
- package/dist/worker/routes/health.js.map +1 -1
- package/dist/worker/routes/index.d.ts +1 -1
- package/dist/worker/routes/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/auth/routes/google-callback.ts","../../../src/internal/admin/settings.ts","../../../src/util/env.ts","../../../src/platform/runtime.ts","../../../src/platform/cloudflare-runtime.ts","../../../src/platform/current.ts","../../../src/internal/admin/admin.ts","../../../src/auth/users.ts","../../../src/auth/user-session.ts"],"sourcesContent":["// auth/routes/google-callback.ts\n//\n// GET /api/auth/google/callback — Google OAuth callback:\n// 1. Verify the `state` cookie matches the URL parameter.\n// 2. Exchange the `code` for an access_token (using the\n// admin-configured client_id/secret).\n// 3. Fetch the user's Google profile.\n// 4. Upsert the user into the D1 `users` table.\n// 5. Issue an HMAC-signed session cookie.\n// 6. 302 redirect to /admin.\n\nimport { NextResponse } from \"next/server\";\nimport { cookies } from \"next/headers\";\nimport { getGoogleOAuthConfig } from \"../../internal/admin/settings\";\nimport { upsertGoogleUser, userToSession } from \"../users\";\nimport { signUserToken, USER_COOKIE } from \"../user-session\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst STATE_COOKIE = \"vinext_oauth_state\";\n\nexport async function GET(request: Request) {\n const config = await getGoogleOAuthConfig();\n if (!config) {\n return new NextResponse(\"Google OAuth not configured\", { status: 503 });\n }\n\n const url = new URL(request.url);\n const code = url.searchParams.get(\"code\");\n const state = url.searchParams.get(\"state\");\n const error = url.searchParams.get(\"error\");\n\n if (error) {\n return new NextResponse(`Google OAuth error: ${error}`, { status: 400 });\n }\n if (!code || !state) {\n return new NextResponse(\"Missing code or state\", { status: 400 });\n }\n\n // 1. 验证 state\n const jar = await cookies();\n const savedState = jar.get(STATE_COOKIE)?.value;\n if (!savedState || savedState !== state) {\n return new NextResponse(\"Invalid state (CSRF protection)\", { status: 400 });\n }\n\n const origin = `${url.protocol}//${url.host}`;\n const redirectUri = `${origin}/api/auth/google/callback`;\n\n try {\n // 2. code → access_token\n const tokenRes = await fetch(\"https://oauth2.googleapis.com/token\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n code,\n client_id: config.clientId,\n client_secret: config.clientSecret,\n redirect_uri: redirectUri,\n grant_type: \"authorization_code\",\n }),\n });\n if (!tokenRes.ok) {\n const t = await tokenRes.text();\n throw new Error(`Token exchange failed: ${tokenRes.status} ${t}`);\n }\n const tokens = (await tokenRes.json()) as {\n access_token: string;\n id_token: string;\n };\n\n // 3. access_token → userinfo\n const userRes = await fetch(\"https://www.googleapis.com/oauth2/v2/userinfo\", {\n headers: { Authorization: `Bearer ${tokens.access_token}` },\n });\n if (!userRes.ok) throw new Error(\"Failed to fetch user info\");\n const profile = (await userRes.json()) as {\n id: string;\n email: string;\n verified_email: boolean;\n name: string;\n picture: string;\n };\n\n if (!profile.verified_email) {\n return new NextResponse(\"Google email not verified\", { status: 403 });\n }\n\n // 4. 写 D1\n const user = await upsertGoogleUser({\n email: profile.email,\n name: profile.name,\n picture: profile.picture,\n googleSub: profile.id,\n });\n\n // 5. 发 session cookie\n const token = await signUserToken(userToSession(user));\n\n // 6. 302 → /admin\n const res = NextResponse.redirect(`${origin}/admin`);\n res.cookies.set(USER_COOKIE, token, {\n httpOnly: true,\n secure: url.protocol === \"https:\",\n sameSite: \"lax\",\n path: \"/\",\n maxAge: 60 * 60 * 24 * 7,\n });\n res.cookies.set(STATE_COOKIE, \"\", { path: \"/\", maxAge: 0 });\n return res;\n } catch (e) {\n const msg = e instanceof Error ? e.message : String(e);\n return new NextResponse(`OAuth callback error: ${msg}`, { status: 500 });\n }\n}\n","// lib/settings.ts - 读取和更新后台系统设置(单管理员模型)\n// 数据保存在 SQL 表 app_settings,目前固定为 1 行。\n//\n// Internal to the package — not exposed via package.json exports. The\n// auth helpers (turnstile.ts, users.ts) call into the read functions;\n// admin pages in the starter import the update functions through a\n// re-export shim at `apps/moviebluebook/lib/settings.ts`.\n\nimport { cache } from \"react\";\nimport { workerEnv } from \"../../util/env\";\nimport { getDatabase } from \"../../platform/current\";\nimport {\n buildTurnstilePublicConfig,\n DEFAULT_TURNSTILE_PUBLIC_CONFIG,\n isSchemaDriftError,\n} from \"./schema-guard\";\n\nexport type AppSettings = {\n site_title: string;\n google_enabled: 0 | 1;\n google_client_id: string | null;\n google_client_secret: string | null;\n google_updated_at: string | null;\n turnstile_enabled: 0 | 1;\n turnstile_site_key: string | null;\n turnstile_updated_at: string | null;\n admin_email: string;\n updated_at: string;\n};\n\ntype Row = {\n site_title: string;\n google_enabled: number;\n google_client_id: string | null;\n google_client_secret: string | null;\n google_updated_at: string | null;\n turnstile_enabled: number;\n turnstile_site_key: string | null;\n turnstile_updated_at: string | null;\n admin_email: string;\n updated_at: string;\n};\n\nconst DEFAULT_ADMIN_EMAIL = \"zhaofilms@gmail.com\";\n\nfunction rowToSettings(r: Row): AppSettings {\n return {\n site_title: r.site_title,\n google_enabled: r.google_enabled === 1 ? 1 : 0,\n google_client_id: r.google_client_id,\n google_client_secret: r.google_client_secret,\n google_updated_at: r.google_updated_at,\n turnstile_enabled: r.turnstile_enabled === 1 ? 1 : 0,\n turnstile_site_key: r.turnstile_site_key,\n turnstile_updated_at: r.turnstile_updated_at,\n admin_email: r.admin_email,\n updated_at: r.updated_at,\n };\n}\n\nconst getAppSettingsCached = cache(async (): Promise<AppSettings> => {\n const row = await getDatabase().prepare(\n `SELECT site_title, google_enabled, google_client_id, google_client_secret,\n google_updated_at, turnstile_enabled, turnstile_site_key,\n turnstile_updated_at, admin_email, updated_at\n FROM app_settings WHERE id = 1`\n ).first<Row>();\n if (!row) {\n // 极端情况:迁移未执行\n return {\n site_title: \"vinext Blog\",\n google_enabled: 0,\n google_client_id: null,\n google_client_secret: null,\n google_updated_at: null,\n turnstile_enabled: 0,\n turnstile_site_key: null,\n turnstile_updated_at: null,\n admin_email: DEFAULT_ADMIN_EMAIL,\n updated_at: \"\",\n };\n }\n return rowToSettings(row);\n});\n\nexport async function getAppSettings(): Promise<AppSettings> {\n return getAppSettingsCached();\n}\n\n/** Turnstile 前端可见配置(site key 公开;secret 在 env)。 */\nexport async function getTurnstilePublicConfig(): Promise<{\n enabled: boolean;\n siteKey: string | null;\n secretConfigured: boolean;\n}> {\n try {\n const s = await getAppSettings();\n return buildTurnstilePublicConfig(s, workerEnv);\n } catch (error) {\n if (isSchemaDriftError(error)) {\n console.error(\n \"[settings] turnstile config unavailable due to schema drift; falling back to disabled state\",\n error\n );\n return { ...DEFAULT_TURNSTILE_PUBLIC_CONFIG };\n }\n throw error;\n }\n}\n\nexport async function updateTurnstileConfig(input: {\n enabled: boolean;\n siteKey: string;\n}): Promise<void> {\n const enabled = input.enabled ? 1 : 0;\n await getDatabase().prepare(\n `UPDATE app_settings\n SET turnstile_enabled = ?,\n turnstile_site_key = ?,\n turnstile_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n )\n .bind(enabled, input.siteKey || null)\n .run();\n}\n\nexport async function disableTurnstileConfig(): Promise<void> {\n await getDatabase().prepare(\n `UPDATE app_settings\n SET turnstile_enabled = 0,\n turnstile_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n ).run();\n}\n\n/** Google 登录实际配置:只有 enabled 且 id+secret 都存在才认为可用 */\nexport async function getGoogleOAuthConfig(): Promise<{\n enabled: boolean;\n clientId: string;\n clientSecret: string;\n} | null> {\n const s = await getAppSettings();\n if (!s.google_enabled) return null;\n if (!s.google_client_id || !s.google_client_secret) return null;\n return {\n enabled: true,\n clientId: s.google_client_id,\n clientSecret: s.google_client_secret,\n };\n}\n\nexport async function updateGoogleOAuthConfig(input: {\n enabled: boolean;\n clientId: string;\n clientSecret: string;\n}): Promise<void> {\n const enabled = input.enabled ? 1 : 0;\n await getDatabase().prepare(\n `UPDATE app_settings\n SET google_enabled = ?,\n google_client_id = ?,\n google_client_secret = ?,\n google_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n )\n .bind(enabled, input.clientId, input.clientSecret)\n .run();\n}\n\nexport async function clearGoogleOAuthConfig(): Promise<void> {\n await getDatabase().prepare(\n `UPDATE app_settings\n SET google_enabled = 0,\n google_client_id = NULL,\n google_client_secret = NULL,\n google_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n ).run();\n}\n\nexport async function updateSiteTitle(title: string): Promise<void> {\n await getDatabase().prepare(\n `UPDATE app_settings SET site_title = ?, updated_at = datetime('now') WHERE id = 1`\n )\n .bind(title)\n .run();\n}\n","// lib/env.ts - 集中获取 Cloudflare bindings\n// 用 cloudflare:workers 模块(workerd 内置),作为平台 adapter 的绑定入口\n\n/// <reference types=\"@cloudflare/workers-types\" />\nimport { env } from \"cloudflare:workers\";\n\nexport type AppEnv = {\n DB: D1Database;\n ASSETS: Fetcher;\n IMAGES: ImagesBinding;\n ASSETS_BUCKET?: R2Bucket;\n CONTENT_CACHE?: KVNamespace;\n ADMIN_PASSWORD: string;\n ADMIN_EMAIL?: string;\n SITE_URL?: string;\n RESEND_API_KEY?: string;\n RESEND_FROM?: string;\n // Google OAuth 仍然兼容 Cloudflare Secret 作为兜底。\n // 实际生效值以 app_settings.google_client_id / google_client_secret 为准。\n GOOGLE_CLIENT_ID?: string;\n GOOGLE_CLIENT_SECRET?: string;\n /** Turnstile site key fallback when not stored in app_settings */\n TURNSTILE_SITE_KEY?: string;\n /** Turnstile secret — set via `wrangler secret put TURNSTILE_SECRET_KEY` */\n TURNSTILE_SECRET_KEY?: string;\n /** Notion integration token for the blog data source */\n NOTION_TOKEN?: string;\n /** Notion data source ID used by dataSources.query */\n NOTION_DATA_SOURCE_ID?: string;\n /** Notion data source ID for the public movie catalog */\n NOTION_MOVIES_DATA_SOURCE_ID?: string;\n /** Notion data source ID for localized movie copy */\n NOTION_MOVIE_TRANSLATIONS_DATA_SOURCE_ID?: string;\n /** Optional Notion API base URL for tests or proxies */\n NOTION_API_BASE_URL?: string;\n /** Optional Notion edit URL for admin handoff screens */\n NOTION_EDIT_BASE_URL?: string;\n /** Optional webhook verification token for Notion invalidation */\n NOTION_WEBHOOK_VERIFICATION_TOKEN?: string;\n};\n\n// 强制类型:vinext 把 env 类型放在 env.d.ts(interface VinextEnv extends Env),\n// 但 TS server 经常解析不到。运行时一定有 DB,类型断言保证编译通过。\nexport const workerEnv = env as unknown as AppEnv;\n","import type { AppEnv } from \"../util/env\";\n\nexport type PlatformBindingEnv = Pick<\n AppEnv,\n \"ASSETS_BUCKET\" | \"CONTENT_CACHE\" | \"DB\" | \"IMAGES\"\n>;\n\nexport type StoredObject = {\n body: ReadableStream;\n size: number;\n etag?: string;\n contentType?: string;\n};\n\nexport type ObjectStoragePutOptions = {\n contentType?: string;\n cacheControl?: string;\n metadata?: Record<string, string>;\n};\n\nexport type ObjectStorageListItem = {\n key: string;\n size: number;\n uploaded: Date;\n};\n\nexport type ObjectStorageAdapter = {\n kind: \"r2\";\n get(key: string): Promise<StoredObject | null>;\n put(\n key: string,\n value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob,\n options?: ObjectStoragePutOptions\n ): Promise<void>;\n delete(key: string): Promise<void>;\n list(\n options?: { prefix?: string; limit?: number }\n ): Promise<ObjectStorageListItem[]>;\n};\n\nexport type ImageTransformOptions = {\n width?: number;\n format: \"image/avif\" | \"image/webp\";\n quality: number;\n};\n\nexport type ImageTransformResult = {\n body: ReadableStream;\n contentType: string;\n response(): Response;\n};\n\nexport type ImageTransformerAdapter = {\n kind: \"cloudflare-images\" | \"external\";\n transform(\n body: ReadableStream,\n options: ImageTransformOptions\n ): Promise<ImageTransformResult>;\n};\n\nexport type PublicCacheAdapter = {\n kind: \"cloudflare-cache\" | \"noop\" | \"external\";\n match(key: string): Promise<Response | null>;\n put(key: string, response: Response): Promise<void>;\n delete(key: string): Promise<boolean>;\n};\n\nexport type KeyValueCacheGetOptions = {\n cacheTtl?: number;\n};\n\nexport type KeyValueCachePutOptions = {\n expirationTtl?: number;\n metadata?: Record<string, string | number | boolean | null>;\n};\n\nexport type KeyValueCacheListOptions = {\n prefix?: string;\n limit?: number;\n cursor?: string;\n};\n\nexport type KeyValueCacheListResult = {\n keys: Array<{ name: string }>;\n cursor?: string;\n listComplete: boolean;\n};\n\nexport type KeyValueCacheAdapter = {\n kind: \"workers-kv\" | \"noop\" | \"external\";\n get<T = unknown>(\n key: string,\n options?: KeyValueCacheGetOptions\n ): Promise<T | null>;\n put<T = unknown>(\n key: string,\n value: T,\n options?: KeyValueCachePutOptions\n ): Promise<void>;\n delete(key: string): Promise<void>;\n list(options?: KeyValueCacheListOptions): Promise<KeyValueCacheListResult>;\n};\n\nexport type SqlValue = string | number | boolean | null;\n\nexport type SqlResult<T = Record<string, unknown>> = {\n results?: T[];\n success?: boolean;\n meta?: {\n changes?: number;\n duration?: number;\n last_row_id?: number;\n rows_read?: number;\n rows_written?: number;\n [key: string]: unknown;\n };\n};\n\nexport type SqlPreparedStatement = {\n bind(...values: SqlValue[]): SqlPreparedStatement;\n all<T = Record<string, unknown>>(): Promise<SqlResult<T>>;\n first<T = Record<string, unknown>>(columnName?: string): Promise<T | null>;\n run<T = Record<string, unknown>>(): Promise<SqlResult<T>>;\n};\n\nexport type SqlDatabaseAdapter = {\n kind: \"d1\";\n prepare(query: string): SqlPreparedStatement;\n batch<T = Record<string, unknown>>(\n statements: SqlPreparedStatement[]\n ): Promise<SqlResult<T>[]>;\n};\n\nexport type RuntimePlatform = {\n id: \"cloudflare-workers\";\n database: SqlDatabaseAdapter | null;\n objectStorage: ObjectStorageAdapter | null;\n imageTransformer: ImageTransformerAdapter | null;\n publicCache: PublicCacheAdapter | null;\n keyValueCache: KeyValueCacheAdapter | null;\n};\n\ntype CloudflareCacheLike = Pick<Cache, \"match\" | \"put\" | \"delete\">;\ntype CloudflareKvLike = Pick<KVNamespace, \"get\" | \"put\" | \"delete\" | \"list\">;\n\nfunction cacheRequestForKey(key: string) {\n return new Request(key, { method: \"GET\" });\n}\n\nexport function createCloudflarePublicCacheAdapter(\n cache: CloudflareCacheLike\n): PublicCacheAdapter {\n return {\n kind: \"cloudflare-cache\",\n async match(key) {\n return (await cache.match(cacheRequestForKey(key))) ?? null;\n },\n put(key, response) {\n return cache.put(cacheRequestForKey(key), response);\n },\n delete(key) {\n return cache.delete(cacheRequestForKey(key));\n },\n };\n}\n\nexport function createNoopPublicCacheAdapter(kind: \"noop\" = \"noop\"): PublicCacheAdapter {\n return {\n kind,\n async match() {\n return null;\n },\n async put() {},\n async delete() {\n return false;\n },\n };\n}\n\nexport function createCloudflareKeyValueCacheAdapter(\n namespace: CloudflareKvLike\n): KeyValueCacheAdapter {\n return {\n kind: \"workers-kv\",\n async get<T = unknown>(\n key: string,\n options?: KeyValueCacheGetOptions\n ): Promise<T | null> {\n return (await namespace.get(key, {\n type: \"json\",\n cacheTtl: options?.cacheTtl,\n })) as T | null;\n },\n async put(key, value, options) {\n await namespace.put(key, JSON.stringify(value), {\n expirationTtl: options?.expirationTtl,\n metadata: options?.metadata,\n });\n },\n delete(key) {\n return namespace.delete(key);\n },\n async list(options) {\n const result = await namespace.list({\n prefix: options?.prefix,\n limit: options?.limit,\n cursor: options?.cursor,\n });\n return {\n keys: result.keys.map((key) => ({ name: key.name })),\n cursor: result.list_complete ? undefined : result.cursor,\n listComplete: result.list_complete,\n };\n },\n };\n}\n\nexport function createNoopKeyValueCacheAdapter(\n kind: \"noop\" = \"noop\"\n): KeyValueCacheAdapter {\n return {\n kind,\n async get() {\n return null;\n },\n async put() {},\n async delete() {},\n async list() {\n return { keys: [], listComplete: true };\n },\n };\n}\n\nfunction r2ObjectToStoredObject(object: R2ObjectBody): StoredObject {\n return {\n body: object.body,\n size: object.size,\n etag: object.etag,\n contentType: object.httpMetadata?.contentType,\n };\n}\n\nexport function createCloudflareRuntimePlatform(\n env: PlatformBindingEnv,\n options?: { publicCache?: CloudflareCacheLike | null }\n): RuntimePlatform {\n const database: SqlDatabaseAdapter | null = env.DB\n ? ({\n kind: \"d1\",\n prepare(query: string) {\n return env.DB.prepare(query) as unknown as SqlPreparedStatement;\n },\n async batch(statements: SqlPreparedStatement[]) {\n return (await env.DB.batch(\n statements as unknown as D1PreparedStatement[]\n )) as unknown as SqlResult<Record<string, unknown>>[];\n },\n } as unknown as SqlDatabaseAdapter)\n : null;\n\n const objectStorage: ObjectStorageAdapter | null = env.ASSETS_BUCKET\n ? {\n kind: \"r2\",\n async get(key) {\n const object = await env.ASSETS_BUCKET?.get(key);\n return object ? r2ObjectToStoredObject(object) : null;\n },\n async put(key, value, options) {\n await env.ASSETS_BUCKET?.put(key, value, {\n httpMetadata: {\n contentType: options?.contentType,\n cacheControl: options?.cacheControl,\n },\n customMetadata: options?.metadata,\n });\n },\n async delete(key) {\n await env.ASSETS_BUCKET?.delete(key);\n },\n async list(options) {\n const listed = await env.ASSETS_BUCKET?.list({\n prefix: options?.prefix,\n limit: options?.limit,\n });\n return (\n listed?.objects.map((object) => ({\n key: object.key,\n size: object.size,\n uploaded: object.uploaded,\n })) ?? []\n );\n },\n }\n : null;\n\n const imageTransformer: ImageTransformerAdapter | null = env.IMAGES\n ? {\n kind: \"cloudflare-images\",\n async transform(body, options) {\n const result = await env.IMAGES.input(body)\n .transform(options.width ? { width: options.width } : {})\n .output({\n format: options.format,\n quality: options.quality,\n });\n return {\n body: result.image(),\n contentType: result.contentType(),\n response: () => result.response(),\n };\n },\n }\n : null;\n\n const keyValueCache: KeyValueCacheAdapter | null = env.CONTENT_CACHE\n ? createCloudflareKeyValueCacheAdapter(env.CONTENT_CACHE)\n : null;\n\n return {\n id: \"cloudflare-workers\",\n database,\n objectStorage,\n imageTransformer,\n keyValueCache,\n publicCache: options?.publicCache\n ? createCloudflarePublicCacheAdapter(options.publicCache)\n : null,\n };\n}\n","import { workerEnv } from \"../util/env\";\nimport {\n createCloudflarePublicCacheAdapter,\n createCloudflareRuntimePlatform,\n} from \"./runtime\";\n\nfunction getDefaultCloudflareCache() {\n const globalWithCaches = globalThis as typeof globalThis & {\n caches?: CacheStorage & { default?: Cache };\n };\n return globalWithCaches.caches?.default ?? null;\n}\n\nexport function getRuntimePlatform() {\n return createCloudflareRuntimePlatform(workerEnv, {\n publicCache: getDefaultCloudflareCache(),\n });\n}\n\nexport function getDatabase() {\n const database = getRuntimePlatform().database;\n if (!database) {\n throw new Error(\"SQL database binding not configured\");\n }\n return database;\n}\n\nexport function getPublicCache() {\n const cache = getDefaultCloudflareCache();\n if (!cache) {\n throw new Error(\"Cloudflare cache binding not configured\");\n }\n return createCloudflarePublicCacheAdapter(cache);\n}\n","import {\n getPublicCache as getCloudflarePublicCache,\n getRuntimePlatform as getCloudflareRuntimePlatform,\n} from \"./cloudflare-runtime\";\nimport { currentRuntimeId } from \"./selection\";\n\nexport function getRuntimePlatform() {\n return getCloudflareRuntimePlatform();\n}\n\nexport function getDatabase() {\n const platform = getRuntimePlatform();\n const database = platform.database;\n if (!database) {\n throw new Error(`SQL database adapter not configured for ${platform.id}`);\n }\n return database;\n}\n\nexport function getPublicCache() {\n return getCloudflarePublicCache();\n}\n\nexport function getKeyValueCache() {\n return getRuntimePlatform().keyValueCache;\n}\n\nexport const runtimeSelection = {\n currentRuntimeId,\n};\n","// lib/admin.ts - 单管理员身份识别\n// 设计:固定 admin_email = app_settings.admin_email(默认 zhaofilms@gmail.com)\n// 任何登录方式下,邮箱匹配即视为管理员。\n//\n// Internal to the package — not exposed via package.json exports.\n// The auth helpers (users.ts) call `isAdminEmail` to decide whether a\n// newly registered user gets the `admin` role. The starter still\n// re-exports this through `apps/moviebluebook/lib/admin.ts` for backward\n// compatibility with its own admin pages and tests.\n\nimport { getAppSettings } from \"./settings\";\nimport { getDatabase } from \"../../platform/current\";\n\nexport const DEFAULT_ADMIN_EMAIL = \"zhaofilms@gmail.com\";\n\nexport function normalizeEmail(email: string): string {\n return email.trim().toLowerCase();\n}\n\nexport async function isAdminEmail(email: string): Promise<boolean> {\n if (!email) return false;\n const settings = await getAppSettings();\n return normalizeEmail(email) === normalizeEmail(settings.admin_email);\n}\n\n/**\n * 提升某邮箱为管理员(仅在系统启动时、且匹配 admin_email 时调用)。\n * 实际上是把 users.role 置为 'admin',并清空其它冲突状态。\n */\nexport async function ensureAdminUser(email: string): Promise<void> {\n const normalized = normalizeEmail(email);\n if (!(await isAdminEmail(normalized))) return;\n await getDatabase().prepare(\n `UPDATE users SET role = 'admin' WHERE email = ?`\n ).bind(normalized).run();\n}\n","// auth/users.ts - user persistence for Google OAuth and email/password auth\n//\n// Internal-only module of the auth feature. The exported functions back\n// the email/password registration flow, Google OAuth upserts, the admin\n// user-management API, and the bootstrap that creates the first admin\n// account. Table names and database binding come from the platform\n// runtime (`getDatabase`) which is configured by the consuming app.\n\nimport { hashPassword, verifyPassword } from \"./passwords\";\nimport { isAdminEmail } from \"../internal/admin/admin\";\nimport { getAppSettings } from \"../internal/admin/settings\";\nimport { getDatabase } from \"../platform/current\";\nimport type { SessionUser } from \"./session\";\n\nexport type UserRole = \"user\" | \"vip\" | \"admin\";\nexport type UserListItem = User & { post_count: number };\n\nexport type User = {\n id: number;\n email: string;\n name: string | null;\n picture: string | null;\n google_sub: string | null;\n password_hash: string | null;\n email_verified: number;\n email_verify_token: string | null;\n email_verify_expires_at: string | null;\n password_reset_token: string | null;\n password_reset_expires_at: string | null;\n session_rev: number;\n role: UserRole | null;\n created_at: string;\n last_seen_at: string;\n};\n\nfunction normalizeEmail(email: string): string {\n return email.trim().toLowerCase();\n}\n\nasync function defaultRoleFor(email: string): Promise<\"user\" | \"admin\"> {\n return (await isAdminEmail(email)) ? \"admin\" : \"user\";\n}\n\nexport function normalizeUserRole(role: string | null | undefined): UserRole {\n if (role === \"admin\" || role === \"vip\") return role;\n return \"user\";\n}\n\nfunction createRandomToken(): string {\n return [...crypto.getRandomValues(new Uint8Array(24))]\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\nexport function userToSession(user: User): SessionUser {\n return {\n uid: user.id,\n email: user.email,\n name: user.name,\n picture: user.picture,\n rev: user.session_rev ?? 0,\n };\n}\n\nexport async function upsertGoogleUser(input: {\n email: string;\n name: string;\n picture: string;\n googleSub: string;\n}): Promise<User> {\n const db = getDatabase();\n const email = normalizeEmail(input.email);\n\n const existing = await db.prepare(\n `SELECT * FROM users WHERE google_sub = ? OR email = ? LIMIT 1`\n )\n .bind(input.googleSub, email)\n .first<User>();\n\n if (existing) {\n await db.prepare(\n `UPDATE users\n SET email = ?, name = ?, picture = ?, google_sub = ?, email_verified = 1,\n email_verify_token = NULL, email_verify_expires_at = NULL,\n last_seen_at = datetime('now')\n WHERE id = ?`\n )\n .bind(email, input.name, input.picture, input.googleSub, existing.id)\n .run();\n } else {\n const role = await defaultRoleFor(email);\n await db.prepare(\n `INSERT INTO users (\n email, name, picture, google_sub, email_verified, role, last_seen_at\n ) VALUES (?, ?, ?, ?, 1, ?, datetime('now'))`\n )\n .bind(email, input.name, input.picture, input.googleSub, role)\n .run();\n }\n\n if (await isAdminEmail(email)) {\n await db.prepare(\n `UPDATE users SET role = 'admin' WHERE email = ?`\n ).bind(email).run();\n }\n\n const user = await getUserByEmail(email);\n if (!user) throw new Error(\"User upsert failed\");\n return user;\n}\n\nexport async function createEmailUser(input: {\n email: string;\n password: string;\n}): Promise<\n | { ok: true; user: User; verifyToken: string }\n | { ok: false; reason: \"exists\" }\n> {\n const email = normalizeEmail(input.email);\n const existing = await getUserByEmail(email);\n if (existing) {\n return { ok: false, reason: \"exists\" };\n }\n\n const passwordHash = await hashPassword(input.password);\n const verifyToken = createRandomToken();\n const verifyExpiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString();\n\n const role = await defaultRoleFor(email);\n const db = getDatabase();\n await db.prepare(\n `INSERT INTO users (\n email, password_hash, email_verified, email_verify_token,\n email_verify_expires_at, role, last_seen_at\n ) VALUES (?, ?, 0, ?, ?, ?, datetime('now'))`\n )\n .bind(email, passwordHash, verifyToken, verifyExpiresAt, role)\n .run();\n\n if (role === \"admin\") {\n await db.prepare(\n `UPDATE users SET role = 'admin' WHERE email = ?`\n ).bind(email).run();\n }\n\n const user = await getUserByEmail(email);\n if (!user) throw new Error(\"User creation failed\");\n return { ok: true, user, verifyToken };\n}\n\nexport async function verifyEmailUser(token: string): Promise<User | null> {\n const user = await getDatabase().prepare(\n `SELECT * FROM users WHERE email_verify_token = ?`\n )\n .bind(token)\n .first<User>();\n\n if (!user || !user.email_verify_expires_at) return null;\n if (new Date(user.email_verify_expires_at).getTime() < Date.now()) {\n return null;\n }\n\n await getDatabase().prepare(\n `UPDATE users\n SET email_verified = 1,\n email_verify_token = NULL,\n email_verify_expires_at = NULL,\n last_seen_at = datetime('now')\n WHERE id = ?`\n )\n .bind(user.id)\n .run();\n\n return getUserByEmail(user.email);\n}\n\nexport async function issueVerificationToken(\n email: string\n): Promise<\n | { ok: true; token: string; user: User }\n | { ok: false; reason: \"not_found\" | \"already_verified\" | \"no_password\" }\n> {\n const user = await getUserByEmail(email);\n if (!user) return { ok: false, reason: \"not_found\" };\n if (!user.password_hash) return { ok: false, reason: \"no_password\" };\n if (user.email_verified) return { ok: false, reason: \"already_verified\" };\n\n const verifyToken = createRandomToken();\n const verifyExpiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString();\n\n await getDatabase().prepare(\n `UPDATE users\n SET email_verify_token = ?, email_verify_expires_at = ?\n WHERE id = ?`\n )\n .bind(verifyToken, verifyExpiresAt, user.id)\n .run();\n\n const updated = await getUserByEmail(email);\n if (!updated) return { ok: false, reason: \"not_found\" };\n return { ok: true, token: verifyToken, user: updated };\n}\n\nexport async function issuePasswordResetToken(\n email: string\n): Promise<\n | { ok: true; token: string; user: User }\n | { ok: false; reason: \"not_found\" | \"no_password\" | \"unverified\" }\n> {\n const user = await getUserByEmail(email);\n if (!user || !user.password_hash) {\n return { ok: false, reason: \"not_found\" };\n }\n if (!user.email_verified) {\n return { ok: false, reason: \"unverified\" };\n }\n\n const resetToken = createRandomToken();\n const resetExpiresAt = new Date(Date.now() + 1000 * 60 * 60).toISOString();\n\n await getDatabase().prepare(\n `UPDATE users\n SET password_reset_token = ?, password_reset_expires_at = ?\n WHERE id = ?`\n )\n .bind(resetToken, resetExpiresAt, user.id)\n .run();\n\n const updated = await getUserByEmail(email);\n if (!updated) return { ok: false, reason: \"not_found\" };\n return { ok: true, token: resetToken, user: updated };\n}\n\nexport async function resetPasswordWithToken(input: {\n token: string;\n password: string;\n}): Promise<\n | { ok: true; user: User }\n | { ok: false; reason: \"invalid\" }\n> {\n const user = await getDatabase().prepare(\n `SELECT * FROM users WHERE password_reset_token = ?`\n )\n .bind(input.token)\n .first<User>();\n\n if (!user || !user.password_reset_expires_at) {\n return { ok: false, reason: \"invalid\" };\n }\n if (new Date(user.password_reset_expires_at).getTime() < Date.now()) {\n return { ok: false, reason: \"invalid\" };\n }\n\n const passwordHash = await hashPassword(input.password);\n await getDatabase().prepare(\n `UPDATE users\n SET password_hash = ?,\n password_reset_token = NULL,\n password_reset_expires_at = NULL,\n session_rev = session_rev + 1,\n last_seen_at = datetime('now')\n WHERE id = ?`\n )\n .bind(passwordHash, user.id)\n .run();\n\n const updated = await getUserById(user.id);\n if (!updated) return { ok: false, reason: \"invalid\" };\n return { ok: true, user: updated };\n}\n\nexport async function changeUserPassword(input: {\n userId: number;\n currentPassword: string;\n newPassword: string;\n}): Promise<\n | { ok: true; user: User }\n | { ok: false; reason: \"invalid\" | \"no_password\" }\n> {\n const user = await getUserById(input.userId);\n if (!user || !user.password_hash) {\n return { ok: false, reason: \"no_password\" };\n }\n\n const matches = await verifyPassword(\n input.currentPassword,\n user.password_hash\n );\n if (!matches) {\n return { ok: false, reason: \"invalid\" };\n }\n\n const passwordHash = await hashPassword(input.newPassword);\n await getDatabase().prepare(\n `UPDATE users\n SET password_hash = ?,\n session_rev = session_rev + 1,\n last_seen_at = datetime('now')\n WHERE id = ?`\n )\n .bind(passwordHash, user.id)\n .run();\n\n const updated = await getUserById(user.id);\n if (!updated) return { ok: false, reason: \"invalid\" };\n return { ok: true, user: updated };\n}\n\nexport async function authenticateEmailUser(input: {\n email: string;\n password: string;\n}): Promise<\n | { ok: true; user: User }\n | { ok: false; reason: \"invalid\" | \"unverified\" }\n> {\n const email = normalizeEmail(input.email);\n const user = await getUserByEmail(email);\n if (!user || !user.password_hash) {\n return { ok: false, reason: \"invalid\" };\n }\n\n const matches = await verifyPassword(input.password, user.password_hash);\n if (!matches) {\n return { ok: false, reason: \"invalid\" };\n }\n if (!user.email_verified) {\n return { ok: false, reason: \"unverified\" };\n }\n\n await getDatabase().prepare(\n `UPDATE users SET last_seen_at = datetime('now') WHERE id = ?`\n )\n .bind(user.id)\n .run();\n\n return { ok: true, user: { ...user, email } };\n}\n\nexport async function getUserByEmail(email: string): Promise<User | null> {\n return await getDatabase().prepare(`SELECT * FROM users WHERE email = ?`)\n .bind(normalizeEmail(email))\n .first<User>();\n}\n\nexport async function getUserById(id: number): Promise<User | null> {\n return await getDatabase().prepare(`SELECT * FROM users WHERE id = ?`)\n .bind(id)\n .first<User>();\n}\n\nexport async function listUsers(limit = 100): Promise<User[]> {\n const { results } = await getDatabase().prepare(\n `SELECT * FROM users ORDER BY created_at DESC LIMIT ?`\n )\n .bind(limit)\n .all<User>();\n return results || [];\n}\n\nexport async function listUsersWithPostCounts(\n limit = 100\n): Promise<UserListItem[]> {\n const { results } = await getDatabase().prepare(\n `SELECT u.*,\n (SELECT COUNT(*) FROM posts p WHERE p.owner_email = u.email) AS post_count\n FROM users u\n ORDER BY u.created_at DESC\n LIMIT ?`\n )\n .bind(limit)\n .all<UserListItem>();\n return results || [];\n}\n\n/** 递增 session_rev,使该用户所有已签发 cookie 立即失效。 */\nexport async function revokeUserSessions(userId: number): Promise<boolean> {\n const user = await getUserById(userId);\n if (!user) return false;\n await getDatabase().prepare(\n `UPDATE users SET session_rev = session_rev + 1 WHERE id = ?`\n )\n .bind(userId)\n .run();\n return true;\n}\n\nexport async function setUserRole(\n userId: number,\n role: Exclude<UserRole, \"admin\">\n): Promise<\n | { ok: true; user: User }\n | { ok: false; reason: \"not_found\" | \"is_admin\" }\n> {\n const user = await getUserById(userId);\n if (!user) return { ok: false, reason: \"not_found\" };\n if (await isAdminEmail(user.email)) {\n return { ok: false, reason: \"is_admin\" };\n }\n\n await getDatabase().prepare(\n `UPDATE users\n SET role = ?,\n last_seen_at = datetime('now')\n WHERE id = ?`\n )\n .bind(role, userId)\n .run();\n\n const updated = await getUserById(userId);\n if (!updated) return { ok: false, reason: \"not_found\" };\n return { ok: true, user: updated };\n}\n\nexport async function deleteUserAccount(userId: number): Promise<\n | { ok: true; email: string }\n | { ok: false; reason: \"not_found\" | \"is_admin\" }\n> {\n const user = await getUserById(userId);\n if (!user) return { ok: false, reason: \"not_found\" };\n if (await isAdminEmail(user.email)) {\n return { ok: false, reason: \"is_admin\" };\n }\n\n const settings = await getAppSettings();\n const adminEmail = settings.admin_email;\n\n const db = getDatabase();\n await db.batch([\n db.prepare(\n `UPDATE posts SET owner_email = ? WHERE owner_email = ?`\n ).bind(adminEmail, user.email),\n db.prepare(`DELETE FROM users WHERE id = ?`).bind(userId),\n ]);\n\n return { ok: true, email: user.email };\n}\n","// auth/user-session.ts - OAuth (Google) session cookie helpers.\n//\n// The session is an HMAC-SHA256-signed base64-encoded JSON payload\n// containing the user identity and an expiry. The HMAC key is the\n// `ADMIN_PASSWORD` secret so projects can reuse one Cloudflare secret\n// for both admin-password and OAuth cookies. The `session_rev` field\n// is incremented to invalidate every cookie for a user at once\n// (used on password reset, account delete, etc.).\n\nimport { cookies } from \"next/headers\";\nimport { getUserById } from \"./users\";\nimport { getDatabase } from \"../platform/current\";\nimport type { SessionUser } from \"./session\";\n\nconst SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days\nconst USER_COOKIE_NAME = \"vinext_user_session\";\nconst ADMIN_COOKIE_NAME = \"vinext_admin_session\";\n\n/** Cookie name holding the OAuth (Google) user session. */\nexport const USER_COOKIE = USER_COOKIE_NAME;\n\n/** Cookie name holding the admin-password session. */\nexport const ADMIN_COOKIE = ADMIN_COOKIE_NAME;\n\ninterface WorkerEnvLike {\n ADMIN_PASSWORD?: string;\n}\n\nfunction getAdminPassword(): string {\n // Prefer the platform env (Cloudflare / vite dev with .dev.vars). Falls\n // back to process.env for environments where neither is configured.\n // The fallback value is intentionally weak; production deployments\n // must set ADMIN_PASSWORD via `wrangler secret put`.\n let fromWorker: string | undefined;\n try {\n // The optional import keeps this module usable in unit tests that\n // do not have the `cloudflare:workers` virtual module.\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const mod = require(\"cloudflare:workers\") as { env?: WorkerEnvLike };\n fromWorker = mod.env?.ADMIN_PASSWORD;\n } catch {\n fromWorker = undefined;\n }\n if (fromWorker) return fromWorker;\n return process.env.ADMIN_PASSWORD ?? \"vinext-admin-2026\";\n}\n\nasync function hmac(secret: string, message: string): Promise<string> {\n const enc = new TextEncoder();\n const key = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"]\n );\n const sig = await crypto.subtle.sign(\"HMAC\", key, enc.encode(message));\n return [...new Uint8Array(sig)]\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\nasync function constantTimeEqual(a: string, b: string): Promise<boolean> {\n const aBytes = new TextEncoder().encode(a);\n const bBytes = new TextEncoder().encode(b);\n if (aBytes.length !== bBytes.length) return false;\n let diff = 0;\n for (let i = 0; i < aBytes.length; i++) {\n diff |= aBytes[i]! ^ bBytes[i]!;\n }\n return diff === 0;\n}\n\nasync function signPayload(payload: string): Promise<string> {\n return hmac(getAdminPassword(), payload);\n}\n\nfunction utf8ToBase64(s: string): string {\n const bytes = new TextEncoder().encode(s);\n let bin = \"\";\n for (const b of bytes) bin += String.fromCharCode(b);\n return btoa(bin);\n}\n\nfunction base64ToUtf8(b64: string): string {\n const bin = atob(b64);\n const bytes = new Uint8Array(bin.length);\n for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);\n return new TextDecoder().decode(bytes);\n}\n\n/** 给 OAuth 用户发 token:HMAC 签名 + base64-encoded JSON payload */\nexport async function signUserToken(user: SessionUser): Promise<string> {\n const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;\n const payload = { ...user, exp };\n const json = JSON.stringify(payload);\n const b64 = utf8ToBase64(json);\n const sig = await signPayload(b64);\n return `${b64}.${sig}`;\n}\n\nexport async function setUserSessionCookie(user: SessionUser) {\n const token = await signUserToken(user);\n const jar = await cookies();\n jar.set(USER_COOKIE_NAME, token, {\n httpOnly: true,\n secure: process.env.NODE_ENV === \"production\",\n sameSite: \"lax\",\n path: \"/\",\n maxAge: SESSION_TTL_SECONDS,\n });\n}\n\n/** 校验用户 session token:签名、过期、session_rev 是否与数据库一致 */\nexport async function verifyUserToken(\n token: string | undefined\n): Promise<SessionUser | null> {\n if (!token) return null;\n const parts = token.split(\".\");\n if (parts.length !== 2) return null;\n const [b64, sig] = parts;\n const expected = await signPayload(b64!);\n if (!(await constantTimeEqual(sig!, expected))) return null;\n try {\n const json = base64ToUtf8(b64!);\n const payload = JSON.parse(json) as SessionUser & { exp: number };\n if (payload.exp < Math.floor(Date.now() / 1000)) return null;\n\n const dbUser = await getUserById(payload.uid);\n if (!dbUser) return null;\n if (dbUser.email !== payload.email) return null;\n const tokenRev = payload.rev ?? 0;\n const dbRev = dbUser.session_rev ?? 0;\n if (tokenRev !== dbRev) return null;\n\n return {\n uid: payload.uid,\n email: payload.email,\n name: payload.name,\n picture: payload.picture,\n rev: dbRev,\n };\n } catch {\n return null;\n }\n}\n\n/** 当前 OAuth 用户(如果登录了),admin 密码登录时返 null */\nexport async function getCurrentUser(): Promise<SessionUser | null> {\n const jar = await cookies();\n const token = jar.get(USER_COOKIE_NAME)?.value;\n return verifyUserToken(token);\n}\n\nexport async function clearUserSessionCookie() {\n const jar = await cookies();\n jar.set(USER_COOKIE_NAME, \"\", { path: \"/\", maxAge: 0 });\n}\n\n// ====== Admin-password session ======\n\nasync function signAdminToken(): Promise<string> {\n const password = getAdminPassword();\n const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;\n const payload = `ok.${exp}`;\n const sig = await hmac(password, payload);\n return `${payload}.${sig}`;\n}\n\nasync function verifyAdminToken(token: string | undefined): Promise<boolean> {\n if (!token) return false;\n const parts = token.split(\".\");\n if (parts.length !== 3) return false;\n const [flag, expStr, sig] = parts;\n if (flag !== \"ok\") return false;\n const exp = Number(expStr);\n if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return false;\n const password = getAdminPassword();\n const expected = await hmac(password, `${flag}.${exp}`);\n return constantTimeEqual(sig!, expected);\n}\n\nexport async function checkPassword(input: string): Promise<boolean> {\n const expected = getAdminPassword();\n return constantTimeEqual(input, expected);\n}\n\nexport async function setSessionCookie() {\n const token = await signAdminToken();\n const jar = await cookies();\n jar.set(ADMIN_COOKIE_NAME, token, {\n httpOnly: true,\n secure: process.env.NODE_ENV === \"production\",\n sameSite: \"lax\",\n path: \"/\",\n maxAge: SESSION_TTL_SECONDS,\n });\n}\n\nexport async function clearSessionCookie() {\n const jar = await cookies();\n jar.set(ADMIN_COOKIE_NAME, \"\", { path: \"/\", maxAge: 0 });\n}\n\nfunction getAdminEmailFromEnv(): string {\n let fromWorker: string | undefined;\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const mod = require(\"cloudflare:workers\") as { env?: { ADMIN_EMAIL?: string } };\n fromWorker = mod.env?.ADMIN_EMAIL;\n } catch {\n fromWorker = undefined;\n }\n return (fromWorker || \"zhaofilms@gmail.com\").toLowerCase();\n}\n\nexport type AuthViewer = {\n email: string;\n user: SessionUser | null;\n role: \"user\" | \"vip\" | \"admin\";\n isAdmin: boolean;\n isVip: boolean;\n canViewVipContent: boolean;\n};\n\n/**\n * Combined viewer: returns an `AuthViewer` for the current request\n * regardless of which login method (admin password vs OAuth) the\n * user used. `null` means the request is unauthenticated.\n */\nexport async function getAuthViewer(): Promise<AuthViewer | null> {\n const jar = await cookies();\n\n if (await verifyAdminToken(jar.get(ADMIN_COOKIE_NAME)?.value)) {\n return {\n email: getAdminEmailFromEnv(),\n user: null,\n role: \"admin\",\n isAdmin: true,\n isVip: true,\n canViewVipContent: true,\n };\n }\n\n const user = await verifyUserToken(jar.get(USER_COOKIE_NAME)?.value);\n if (!user) return null;\n\n const dbUser = await getUserById(user.uid);\n if (!dbUser) return null;\n const role: \"user\" | \"vip\" | \"admin\" =\n dbUser.role === \"admin\" || dbUser.role === \"vip\" ? dbUser.role : \"user\";\n const isAdmin = role === \"admin\";\n const isVip = role === \"vip\" || isAdmin;\n\n return {\n email: user.email,\n user,\n role,\n isAdmin,\n isVip,\n canViewVipContent: isVip,\n };\n}\n\n/**\n * Extended isAuthenticated: admin password login or OAuth login both\n * count as authenticated.\n */\nexport async function isAuthenticated(): Promise<boolean> {\n const jar = await cookies();\n if (await verifyAdminToken(jar.get(ADMIN_COOKIE_NAME)?.value)) return true;\n if (await verifyUserToken(jar.get(USER_COOKIE_NAME)?.value)) return true;\n return false;\n}\n\n// Re-export the database helper to keep callers from having to reach\n// into the platform layer just to update session_rev after a reset.\nexport { getDatabase };\n"],"mappings":"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","names":["cookies","cache","env","options","getRuntimePlatform","normalizeEmail","normalizeEmail","normalizeEmail","cookies"]}
|
|
1
|
+
{"version":3,"sources":["../../../src/auth/routes/google-callback.ts","../../../src/internal/admin/settings.ts","../../../src/util/env.ts","../../../src/platform/runtime.ts","../../../src/platform/cloudflare-runtime.ts","../../../src/platform/current.ts","../../../src/internal/admin/admin.ts","../../../src/auth/users.ts","../../../src/auth/user-session.ts"],"sourcesContent":["// auth/routes/google-callback.ts\n//\n// GET /api/auth/google/callback — Google OAuth callback:\n// 1. Verify the `state` cookie matches the URL parameter.\n// 2. Exchange the `code` for an access_token (using the\n// admin-configured client_id/secret).\n// 3. Fetch the user's Google profile.\n// 4. Upsert the user into the D1 `users` table.\n// 5. Issue an HMAC-signed session cookie.\n// 6. 302 redirect to /admin.\n\nimport { NextResponse } from \"next/server\";\nimport { cookies } from \"next/headers\";\nimport { getGoogleOAuthConfig } from \"../../internal/admin/settings\";\nimport { upsertGoogleUser, userToSession } from \"../users\";\nimport { signUserToken, USER_COOKIE } from \"../user-session\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst STATE_COOKIE = \"vinext_oauth_state\";\n\nexport async function GET(request: Request) {\n const config = await getGoogleOAuthConfig();\n if (!config) {\n return new NextResponse(\"Google OAuth not configured\", { status: 503 });\n }\n\n const url = new URL(request.url);\n const code = url.searchParams.get(\"code\");\n const state = url.searchParams.get(\"state\");\n const error = url.searchParams.get(\"error\");\n\n if (error) {\n return new NextResponse(`Google OAuth error: ${error}`, { status: 400 });\n }\n if (!code || !state) {\n return new NextResponse(\"Missing code or state\", { status: 400 });\n }\n\n // 1. 验证 state\n const jar = await cookies();\n const savedState = jar.get(STATE_COOKIE)?.value;\n if (!savedState || savedState !== state) {\n return new NextResponse(\"Invalid state (CSRF protection)\", { status: 400 });\n }\n\n const origin = `${url.protocol}//${url.host}`;\n const redirectUri = `${origin}/api/auth/google/callback`;\n\n try {\n // 2. code → access_token\n const tokenRes = await fetch(\"https://oauth2.googleapis.com/token\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n code,\n client_id: config.clientId,\n client_secret: config.clientSecret,\n redirect_uri: redirectUri,\n grant_type: \"authorization_code\",\n }),\n });\n if (!tokenRes.ok) {\n const t = await tokenRes.text();\n throw new Error(`Token exchange failed: ${tokenRes.status} ${t}`);\n }\n const tokens = (await tokenRes.json()) as {\n access_token: string;\n id_token: string;\n };\n\n // 3. access_token → userinfo\n const userRes = await fetch(\"https://www.googleapis.com/oauth2/v2/userinfo\", {\n headers: { Authorization: `Bearer ${tokens.access_token}` },\n });\n if (!userRes.ok) throw new Error(\"Failed to fetch user info\");\n const profile = (await userRes.json()) as {\n id: string;\n email: string;\n verified_email: boolean;\n name: string;\n picture: string;\n };\n\n if (!profile.verified_email) {\n return new NextResponse(\"Google email not verified\", { status: 403 });\n }\n\n // 4. 写 D1\n const user = await upsertGoogleUser({\n email: profile.email,\n name: profile.name,\n picture: profile.picture,\n googleSub: profile.id,\n });\n\n // 5. 发 session cookie\n const token = await signUserToken(userToSession(user));\n\n // 6. 302 → /admin\n const res = NextResponse.redirect(`${origin}/admin`);\n res.cookies.set(USER_COOKIE, token, {\n httpOnly: true,\n secure: url.protocol === \"https:\",\n sameSite: \"lax\",\n path: \"/\",\n maxAge: 60 * 60 * 24 * 7,\n });\n res.cookies.set(STATE_COOKIE, \"\", { path: \"/\", maxAge: 0 });\n return res;\n } catch (e) {\n const msg = e instanceof Error ? e.message : String(e);\n return new NextResponse(`OAuth callback error: ${msg}`, { status: 500 });\n }\n}\n","// lib/settings.ts - 读取和更新后台系统设置(单管理员模型)\n// 数据保存在 SQL 表 app_settings,目前固定为 1 行。\n//\n// Internal to the package — not exposed via package.json exports. The\n// auth helpers (turnstile.ts, users.ts) call into the read functions;\n// consumer apps may re-export the update functions for local admin pages.\n\nimport { cache } from \"react\";\nimport { workerEnv } from \"../../util/env\";\nimport { getDatabase } from \"../../platform/current\";\nimport {\n buildTurnstilePublicConfig,\n DEFAULT_TURNSTILE_PUBLIC_CONFIG,\n isSchemaDriftError,\n} from \"./schema-guard\";\n\nexport type AppSettings = {\n site_title: string;\n google_enabled: 0 | 1;\n google_client_id: string | null;\n google_client_secret: string | null;\n google_updated_at: string | null;\n turnstile_enabled: 0 | 1;\n turnstile_site_key: string | null;\n turnstile_updated_at: string | null;\n admin_email: string;\n updated_at: string;\n};\n\ntype Row = {\n site_title: string;\n google_enabled: number;\n google_client_id: string | null;\n google_client_secret: string | null;\n google_updated_at: string | null;\n turnstile_enabled: number;\n turnstile_site_key: string | null;\n turnstile_updated_at: string | null;\n admin_email: string;\n updated_at: string;\n};\n\nconst DEFAULT_ADMIN_EMAIL = \"zhaofilms@gmail.com\";\n\nfunction rowToSettings(r: Row): AppSettings {\n return {\n site_title: r.site_title,\n google_enabled: r.google_enabled === 1 ? 1 : 0,\n google_client_id: r.google_client_id,\n google_client_secret: r.google_client_secret,\n google_updated_at: r.google_updated_at,\n turnstile_enabled: r.turnstile_enabled === 1 ? 1 : 0,\n turnstile_site_key: r.turnstile_site_key,\n turnstile_updated_at: r.turnstile_updated_at,\n admin_email: r.admin_email,\n updated_at: r.updated_at,\n };\n}\n\nconst getAppSettingsCached = cache(async (): Promise<AppSettings> => {\n const row = await getDatabase().prepare(\n `SELECT site_title, google_enabled, google_client_id, google_client_secret,\n google_updated_at, turnstile_enabled, turnstile_site_key,\n turnstile_updated_at, admin_email, updated_at\n FROM app_settings WHERE id = 1`\n ).first<Row>();\n if (!row) {\n // 极端情况:迁移未执行\n return {\n site_title: \"vinext Blog\",\n google_enabled: 0,\n google_client_id: null,\n google_client_secret: null,\n google_updated_at: null,\n turnstile_enabled: 0,\n turnstile_site_key: null,\n turnstile_updated_at: null,\n admin_email: DEFAULT_ADMIN_EMAIL,\n updated_at: \"\",\n };\n }\n return rowToSettings(row);\n});\n\nexport async function getAppSettings(): Promise<AppSettings> {\n return getAppSettingsCached();\n}\n\n/** Turnstile 前端可见配置(site key 公开;secret 在 env)。 */\nexport async function getTurnstilePublicConfig(): Promise<{\n enabled: boolean;\n siteKey: string | null;\n secretConfigured: boolean;\n}> {\n try {\n const s = await getAppSettings();\n return buildTurnstilePublicConfig(s, workerEnv);\n } catch (error) {\n if (isSchemaDriftError(error)) {\n console.error(\n \"[settings] turnstile config unavailable due to schema drift; falling back to disabled state\",\n error\n );\n return { ...DEFAULT_TURNSTILE_PUBLIC_CONFIG };\n }\n throw error;\n }\n}\n\nexport async function updateTurnstileConfig(input: {\n enabled: boolean;\n siteKey: string;\n}): Promise<void> {\n const enabled = input.enabled ? 1 : 0;\n await getDatabase().prepare(\n `UPDATE app_settings\n SET turnstile_enabled = ?,\n turnstile_site_key = ?,\n turnstile_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n )\n .bind(enabled, input.siteKey || null)\n .run();\n}\n\nexport async function disableTurnstileConfig(): Promise<void> {\n await getDatabase().prepare(\n `UPDATE app_settings\n SET turnstile_enabled = 0,\n turnstile_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n ).run();\n}\n\n/** Google 登录实际配置:只有 enabled 且 id+secret 都存在才认为可用 */\nexport async function getGoogleOAuthConfig(): Promise<{\n enabled: boolean;\n clientId: string;\n clientSecret: string;\n} | null> {\n const s = await getAppSettings();\n if (!s.google_enabled) return null;\n if (!s.google_client_id || !s.google_client_secret) return null;\n return {\n enabled: true,\n clientId: s.google_client_id,\n clientSecret: s.google_client_secret,\n };\n}\n\nexport async function updateGoogleOAuthConfig(input: {\n enabled: boolean;\n clientId: string;\n clientSecret: string;\n}): Promise<void> {\n const enabled = input.enabled ? 1 : 0;\n await getDatabase().prepare(\n `UPDATE app_settings\n SET google_enabled = ?,\n google_client_id = ?,\n google_client_secret = ?,\n google_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n )\n .bind(enabled, input.clientId, input.clientSecret)\n .run();\n}\n\nexport async function clearGoogleOAuthConfig(): Promise<void> {\n await getDatabase().prepare(\n `UPDATE app_settings\n SET google_enabled = 0,\n google_client_id = NULL,\n google_client_secret = NULL,\n google_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n ).run();\n}\n\nexport async function updateSiteTitle(title: string): Promise<void> {\n await getDatabase().prepare(\n `UPDATE app_settings SET site_title = ?, updated_at = datetime('now') WHERE id = 1`\n )\n .bind(title)\n .run();\n}\n","// lib/env.ts - 集中获取 Cloudflare bindings\n// 用 cloudflare:workers 模块(workerd 内置),作为平台 adapter 的绑定入口\n\n/// <reference types=\"@cloudflare/workers-types\" />\nimport { env } from \"cloudflare:workers\";\n\nexport type AppEnv = {\n DB: D1Database;\n ASSETS: Fetcher;\n IMAGES: ImagesBinding;\n ASSETS_BUCKET?: R2Bucket;\n CONTENT_CACHE?: KVNamespace;\n ADMIN_PASSWORD: string;\n ADMIN_EMAIL?: string;\n SITE_URL?: string;\n RESEND_API_KEY?: string;\n RESEND_FROM?: string;\n // Google OAuth 仍然兼容 Cloudflare Secret 作为兜底。\n // 实际生效值以 app_settings.google_client_id / google_client_secret 为准。\n GOOGLE_CLIENT_ID?: string;\n GOOGLE_CLIENT_SECRET?: string;\n /** Turnstile site key fallback when not stored in app_settings */\n TURNSTILE_SITE_KEY?: string;\n /** Turnstile secret — set via `wrangler secret put TURNSTILE_SECRET_KEY` */\n TURNSTILE_SECRET_KEY?: string;\n /** Notion integration token for the blog data source */\n NOTION_TOKEN?: string;\n /** Notion data source ID used by dataSources.query */\n NOTION_DATA_SOURCE_ID?: string;\n /** Optional Notion API base URL for tests or proxies */\n NOTION_API_BASE_URL?: string;\n /** Optional Notion edit URL for admin handoff screens */\n NOTION_EDIT_BASE_URL?: string;\n /** Optional webhook verification token for Notion invalidation */\n NOTION_WEBHOOK_VERIFICATION_TOKEN?: string;\n};\n\n// 强制类型:vinext 把 env 类型放在 env.d.ts(interface VinextEnv extends Env),\n// 但 TS server 经常解析不到。运行时一定有 DB,类型断言保证编译通过。\nexport const workerEnv = env as unknown as AppEnv;\n","import type { AppEnv } from \"../util/env\";\n\nexport type PlatformBindingEnv = Pick<\n AppEnv,\n \"ASSETS_BUCKET\" | \"CONTENT_CACHE\" | \"DB\" | \"IMAGES\"\n>;\n\nexport type StoredObject = {\n body: ReadableStream;\n size: number;\n etag?: string;\n contentType?: string;\n};\n\nexport type ObjectStoragePutOptions = {\n contentType?: string;\n cacheControl?: string;\n metadata?: Record<string, string>;\n};\n\nexport type ObjectStorageListItem = {\n key: string;\n size: number;\n uploaded: Date;\n};\n\nexport type ObjectStorageAdapter = {\n kind: \"r2\";\n get(key: string): Promise<StoredObject | null>;\n put(\n key: string,\n value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob,\n options?: ObjectStoragePutOptions\n ): Promise<void>;\n delete(key: string): Promise<void>;\n list(\n options?: { prefix?: string; limit?: number }\n ): Promise<ObjectStorageListItem[]>;\n};\n\nexport type ImageTransformOptions = {\n width?: number;\n format: \"image/avif\" | \"image/webp\";\n quality: number;\n};\n\nexport type ImageTransformResult = {\n body: ReadableStream;\n contentType: string;\n response(): Response;\n};\n\nexport type ImageTransformerAdapter = {\n kind: \"cloudflare-images\" | \"external\";\n transform(\n body: ReadableStream,\n options: ImageTransformOptions\n ): Promise<ImageTransformResult>;\n};\n\nexport type PublicCacheAdapter = {\n kind: \"cloudflare-cache\" | \"noop\" | \"external\";\n match(key: string): Promise<Response | null>;\n put(key: string, response: Response): Promise<void>;\n delete(key: string): Promise<boolean>;\n};\n\nexport type KeyValueCacheGetOptions = {\n cacheTtl?: number;\n};\n\nexport type KeyValueCachePutOptions = {\n expirationTtl?: number;\n metadata?: Record<string, string | number | boolean | null>;\n};\n\nexport type KeyValueCacheListOptions = {\n prefix?: string;\n limit?: number;\n cursor?: string;\n};\n\nexport type KeyValueCacheListResult = {\n keys: Array<{ name: string }>;\n cursor?: string;\n listComplete: boolean;\n};\n\nexport type KeyValueCacheAdapter = {\n kind: \"workers-kv\" | \"noop\" | \"external\";\n get<T = unknown>(\n key: string,\n options?: KeyValueCacheGetOptions\n ): Promise<T | null>;\n put<T = unknown>(\n key: string,\n value: T,\n options?: KeyValueCachePutOptions\n ): Promise<void>;\n delete(key: string): Promise<void>;\n list(options?: KeyValueCacheListOptions): Promise<KeyValueCacheListResult>;\n};\n\nexport type SqlValue = string | number | boolean | null;\n\nexport type SqlResult<T = Record<string, unknown>> = {\n results?: T[];\n success?: boolean;\n meta?: {\n changes?: number;\n duration?: number;\n last_row_id?: number;\n rows_read?: number;\n rows_written?: number;\n [key: string]: unknown;\n };\n};\n\nexport type SqlPreparedStatement = {\n bind(...values: SqlValue[]): SqlPreparedStatement;\n all<T = Record<string, unknown>>(): Promise<SqlResult<T>>;\n first<T = Record<string, unknown>>(columnName?: string): Promise<T | null>;\n run<T = Record<string, unknown>>(): Promise<SqlResult<T>>;\n};\n\nexport type SqlDatabaseAdapter = {\n kind: \"d1\";\n prepare(query: string): SqlPreparedStatement;\n batch<T = Record<string, unknown>>(\n statements: SqlPreparedStatement[]\n ): Promise<SqlResult<T>[]>;\n};\n\nexport type RuntimePlatform = {\n id: \"cloudflare-workers\";\n database: SqlDatabaseAdapter | null;\n objectStorage: ObjectStorageAdapter | null;\n imageTransformer: ImageTransformerAdapter | null;\n publicCache: PublicCacheAdapter | null;\n keyValueCache: KeyValueCacheAdapter | null;\n};\n\ntype CloudflareCacheLike = Pick<Cache, \"match\" | \"put\" | \"delete\">;\ntype CloudflareKvLike = Pick<KVNamespace, \"get\" | \"put\" | \"delete\" | \"list\">;\n\nfunction cacheRequestForKey(key: string) {\n return new Request(key, { method: \"GET\" });\n}\n\nexport function createCloudflarePublicCacheAdapter(\n cache: CloudflareCacheLike\n): PublicCacheAdapter {\n return {\n kind: \"cloudflare-cache\",\n async match(key) {\n return (await cache.match(cacheRequestForKey(key))) ?? null;\n },\n put(key, response) {\n return cache.put(cacheRequestForKey(key), response);\n },\n delete(key) {\n return cache.delete(cacheRequestForKey(key));\n },\n };\n}\n\nexport function createNoopPublicCacheAdapter(kind: \"noop\" = \"noop\"): PublicCacheAdapter {\n return {\n kind,\n async match() {\n return null;\n },\n async put() {},\n async delete() {\n return false;\n },\n };\n}\n\nexport function createCloudflareKeyValueCacheAdapter(\n namespace: CloudflareKvLike\n): KeyValueCacheAdapter {\n return {\n kind: \"workers-kv\",\n async get<T = unknown>(\n key: string,\n options?: KeyValueCacheGetOptions\n ): Promise<T | null> {\n return (await namespace.get(key, {\n type: \"json\",\n cacheTtl: options?.cacheTtl,\n })) as T | null;\n },\n async put(key, value, options) {\n await namespace.put(key, JSON.stringify(value), {\n expirationTtl: options?.expirationTtl,\n metadata: options?.metadata,\n });\n },\n delete(key) {\n return namespace.delete(key);\n },\n async list(options) {\n const result = await namespace.list({\n prefix: options?.prefix,\n limit: options?.limit,\n cursor: options?.cursor,\n });\n return {\n keys: result.keys.map((key) => ({ name: key.name })),\n cursor: result.list_complete ? undefined : result.cursor,\n listComplete: result.list_complete,\n };\n },\n };\n}\n\nexport function createNoopKeyValueCacheAdapter(\n kind: \"noop\" = \"noop\"\n): KeyValueCacheAdapter {\n return {\n kind,\n async get() {\n return null;\n },\n async put() {},\n async delete() {},\n async list() {\n return { keys: [], listComplete: true };\n },\n };\n}\n\nfunction r2ObjectToStoredObject(object: R2ObjectBody): StoredObject {\n return {\n body: object.body,\n size: object.size,\n etag: object.etag,\n contentType: object.httpMetadata?.contentType,\n };\n}\n\nexport function createCloudflareRuntimePlatform(\n env: PlatformBindingEnv,\n options?: { publicCache?: CloudflareCacheLike | null }\n): RuntimePlatform {\n const database: SqlDatabaseAdapter | null = env.DB\n ? ({\n kind: \"d1\",\n prepare(query: string) {\n return env.DB.prepare(query) as unknown as SqlPreparedStatement;\n },\n async batch(statements: SqlPreparedStatement[]) {\n return (await env.DB.batch(\n statements as unknown as D1PreparedStatement[]\n )) as unknown as SqlResult<Record<string, unknown>>[];\n },\n } as unknown as SqlDatabaseAdapter)\n : null;\n\n const objectStorage: ObjectStorageAdapter | null = env.ASSETS_BUCKET\n ? {\n kind: \"r2\",\n async get(key) {\n const object = await env.ASSETS_BUCKET?.get(key);\n return object ? r2ObjectToStoredObject(object) : null;\n },\n async put(key, value, options) {\n await env.ASSETS_BUCKET?.put(key, value, {\n httpMetadata: {\n contentType: options?.contentType,\n cacheControl: options?.cacheControl,\n },\n customMetadata: options?.metadata,\n });\n },\n async delete(key) {\n await env.ASSETS_BUCKET?.delete(key);\n },\n async list(options) {\n const listed = await env.ASSETS_BUCKET?.list({\n prefix: options?.prefix,\n limit: options?.limit,\n });\n return (\n listed?.objects.map((object) => ({\n key: object.key,\n size: object.size,\n uploaded: object.uploaded,\n })) ?? []\n );\n },\n }\n : null;\n\n const imageTransformer: ImageTransformerAdapter | null = env.IMAGES\n ? {\n kind: \"cloudflare-images\",\n async transform(body, options) {\n const result = await env.IMAGES.input(body)\n .transform(options.width ? { width: options.width } : {})\n .output({\n format: options.format,\n quality: options.quality,\n });\n return {\n body: result.image(),\n contentType: result.contentType(),\n response: () => result.response(),\n };\n },\n }\n : null;\n\n const keyValueCache: KeyValueCacheAdapter | null = env.CONTENT_CACHE\n ? createCloudflareKeyValueCacheAdapter(env.CONTENT_CACHE)\n : null;\n\n return {\n id: \"cloudflare-workers\",\n database,\n objectStorage,\n imageTransformer,\n keyValueCache,\n publicCache: options?.publicCache\n ? createCloudflarePublicCacheAdapter(options.publicCache)\n : null,\n };\n}\n","import { workerEnv } from \"../util/env\";\nimport {\n createCloudflarePublicCacheAdapter,\n createCloudflareRuntimePlatform,\n} from \"./runtime\";\n\nfunction getDefaultCloudflareCache() {\n const globalWithCaches = globalThis as typeof globalThis & {\n caches?: CacheStorage & { default?: Cache };\n };\n return globalWithCaches.caches?.default ?? null;\n}\n\nexport function getRuntimePlatform() {\n return createCloudflareRuntimePlatform(workerEnv, {\n publicCache: getDefaultCloudflareCache(),\n });\n}\n\nexport function getDatabase() {\n const database = getRuntimePlatform().database;\n if (!database) {\n throw new Error(\"SQL database binding not configured\");\n }\n return database;\n}\n\nexport function getPublicCache() {\n const cache = getDefaultCloudflareCache();\n if (!cache) {\n throw new Error(\"Cloudflare cache binding not configured\");\n }\n return createCloudflarePublicCacheAdapter(cache);\n}\n","import {\n getPublicCache as getCloudflarePublicCache,\n getRuntimePlatform as getCloudflareRuntimePlatform,\n} from \"./cloudflare-runtime\";\nimport { currentRuntimeId } from \"./selection\";\n\nexport function getRuntimePlatform() {\n return getCloudflareRuntimePlatform();\n}\n\nexport function getDatabase() {\n const platform = getRuntimePlatform();\n const database = platform.database;\n if (!database) {\n throw new Error(`SQL database adapter not configured for ${platform.id}`);\n }\n return database;\n}\n\nexport function getPublicCache() {\n return getCloudflarePublicCache();\n}\n\nexport function getKeyValueCache() {\n return getRuntimePlatform().keyValueCache;\n}\n\nexport const runtimeSelection = {\n currentRuntimeId,\n};\n","// lib/admin.ts - 单管理员身份识别\n// 设计:固定 admin_email = app_settings.admin_email(默认 zhaofilms@gmail.com)\n// 任何登录方式下,邮箱匹配即视为管理员。\n//\n// Internal to the package — not exposed via package.json exports.\n// The auth helpers (users.ts) call `isAdminEmail` to decide whether a\n// newly registered user gets the `admin` role. Consumer apps may\n// re-export this for backward compatibility with local imports.\n\nimport { getAppSettings } from \"./settings\";\nimport { getDatabase } from \"../../platform/current\";\n\nexport const DEFAULT_ADMIN_EMAIL = \"zhaofilms@gmail.com\";\n\nexport function normalizeEmail(email: string): string {\n return email.trim().toLowerCase();\n}\n\nexport async function isAdminEmail(email: string): Promise<boolean> {\n if (!email) return false;\n const settings = await getAppSettings();\n return normalizeEmail(email) === normalizeEmail(settings.admin_email);\n}\n\n/**\n * 提升某邮箱为管理员(仅在系统启动时、且匹配 admin_email 时调用)。\n * 实际上是把 users.role 置为 'admin',并清空其它冲突状态。\n */\nexport async function ensureAdminUser(email: string): Promise<void> {\n const normalized = normalizeEmail(email);\n if (!(await isAdminEmail(normalized))) return;\n await getDatabase().prepare(\n `UPDATE users SET role = 'admin' WHERE email = ?`\n ).bind(normalized).run();\n}\n","// auth/users.ts - user persistence for Google OAuth and email/password auth\n//\n// Internal-only module of the auth feature. The exported functions back\n// the email/password registration flow, Google OAuth upserts, the admin\n// user-management API, and the bootstrap that creates the first admin\n// account. Table names and database binding come from the platform\n// runtime (`getDatabase`) which is configured by the consuming app.\n\nimport { hashPassword, verifyPassword } from \"./passwords\";\nimport { isAdminEmail } from \"../internal/admin/admin\";\nimport { getAppSettings } from \"../internal/admin/settings\";\nimport { getDatabase } from \"../platform/current\";\nimport type { SessionUser } from \"./session\";\n\nexport type UserRole = \"user\" | \"vip\" | \"admin\";\nexport type UserListItem = User & { post_count: number };\n\nexport type User = {\n id: number;\n email: string;\n name: string | null;\n picture: string | null;\n google_sub: string | null;\n password_hash: string | null;\n email_verified: number;\n email_verify_token: string | null;\n email_verify_expires_at: string | null;\n password_reset_token: string | null;\n password_reset_expires_at: string | null;\n session_rev: number;\n role: UserRole | null;\n created_at: string;\n last_seen_at: string;\n};\n\nfunction normalizeEmail(email: string): string {\n return email.trim().toLowerCase();\n}\n\nasync function defaultRoleFor(email: string): Promise<\"user\" | \"admin\"> {\n return (await isAdminEmail(email)) ? \"admin\" : \"user\";\n}\n\nexport function normalizeUserRole(role: string | null | undefined): UserRole {\n if (role === \"admin\" || role === \"vip\") return role;\n return \"user\";\n}\n\nfunction createRandomToken(): string {\n return [...crypto.getRandomValues(new Uint8Array(24))]\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\nexport function userToSession(user: User): SessionUser {\n return {\n uid: user.id,\n email: user.email,\n name: user.name,\n picture: user.picture,\n rev: user.session_rev ?? 0,\n };\n}\n\nexport async function upsertGoogleUser(input: {\n email: string;\n name: string;\n picture: string;\n googleSub: string;\n}): Promise<User> {\n const db = getDatabase();\n const email = normalizeEmail(input.email);\n\n const existing = await db.prepare(\n `SELECT * FROM users WHERE google_sub = ? OR email = ? LIMIT 1`\n )\n .bind(input.googleSub, email)\n .first<User>();\n\n if (existing) {\n await db.prepare(\n `UPDATE users\n SET email = ?, name = ?, picture = ?, google_sub = ?, email_verified = 1,\n email_verify_token = NULL, email_verify_expires_at = NULL,\n last_seen_at = datetime('now')\n WHERE id = ?`\n )\n .bind(email, input.name, input.picture, input.googleSub, existing.id)\n .run();\n } else {\n const role = await defaultRoleFor(email);\n await db.prepare(\n `INSERT INTO users (\n email, name, picture, google_sub, email_verified, role, last_seen_at\n ) VALUES (?, ?, ?, ?, 1, ?, datetime('now'))`\n )\n .bind(email, input.name, input.picture, input.googleSub, role)\n .run();\n }\n\n if (await isAdminEmail(email)) {\n await db.prepare(\n `UPDATE users SET role = 'admin' WHERE email = ?`\n ).bind(email).run();\n }\n\n const user = await getUserByEmail(email);\n if (!user) throw new Error(\"User upsert failed\");\n return user;\n}\n\nexport async function createEmailUser(input: {\n email: string;\n password: string;\n}): Promise<\n | { ok: true; user: User; verifyToken: string }\n | { ok: false; reason: \"exists\" }\n> {\n const email = normalizeEmail(input.email);\n const existing = await getUserByEmail(email);\n if (existing) {\n return { ok: false, reason: \"exists\" };\n }\n\n const passwordHash = await hashPassword(input.password);\n const verifyToken = createRandomToken();\n const verifyExpiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString();\n\n const role = await defaultRoleFor(email);\n const db = getDatabase();\n await db.prepare(\n `INSERT INTO users (\n email, password_hash, email_verified, email_verify_token,\n email_verify_expires_at, role, last_seen_at\n ) VALUES (?, ?, 0, ?, ?, ?, datetime('now'))`\n )\n .bind(email, passwordHash, verifyToken, verifyExpiresAt, role)\n .run();\n\n if (role === \"admin\") {\n await db.prepare(\n `UPDATE users SET role = 'admin' WHERE email = ?`\n ).bind(email).run();\n }\n\n const user = await getUserByEmail(email);\n if (!user) throw new Error(\"User creation failed\");\n return { ok: true, user, verifyToken };\n}\n\nexport async function verifyEmailUser(token: string): Promise<User | null> {\n const user = await getDatabase().prepare(\n `SELECT * FROM users WHERE email_verify_token = ?`\n )\n .bind(token)\n .first<User>();\n\n if (!user || !user.email_verify_expires_at) return null;\n if (new Date(user.email_verify_expires_at).getTime() < Date.now()) {\n return null;\n }\n\n await getDatabase().prepare(\n `UPDATE users\n SET email_verified = 1,\n email_verify_token = NULL,\n email_verify_expires_at = NULL,\n last_seen_at = datetime('now')\n WHERE id = ?`\n )\n .bind(user.id)\n .run();\n\n return getUserByEmail(user.email);\n}\n\nexport async function issueVerificationToken(\n email: string\n): Promise<\n | { ok: true; token: string; user: User }\n | { ok: false; reason: \"not_found\" | \"already_verified\" | \"no_password\" }\n> {\n const user = await getUserByEmail(email);\n if (!user) return { ok: false, reason: \"not_found\" };\n if (!user.password_hash) return { ok: false, reason: \"no_password\" };\n if (user.email_verified) return { ok: false, reason: \"already_verified\" };\n\n const verifyToken = createRandomToken();\n const verifyExpiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString();\n\n await getDatabase().prepare(\n `UPDATE users\n SET email_verify_token = ?, email_verify_expires_at = ?\n WHERE id = ?`\n )\n .bind(verifyToken, verifyExpiresAt, user.id)\n .run();\n\n const updated = await getUserByEmail(email);\n if (!updated) return { ok: false, reason: \"not_found\" };\n return { ok: true, token: verifyToken, user: updated };\n}\n\nexport async function issuePasswordResetToken(\n email: string\n): Promise<\n | { ok: true; token: string; user: User }\n | { ok: false; reason: \"not_found\" | \"no_password\" | \"unverified\" }\n> {\n const user = await getUserByEmail(email);\n if (!user || !user.password_hash) {\n return { ok: false, reason: \"not_found\" };\n }\n if (!user.email_verified) {\n return { ok: false, reason: \"unverified\" };\n }\n\n const resetToken = createRandomToken();\n const resetExpiresAt = new Date(Date.now() + 1000 * 60 * 60).toISOString();\n\n await getDatabase().prepare(\n `UPDATE users\n SET password_reset_token = ?, password_reset_expires_at = ?\n WHERE id = ?`\n )\n .bind(resetToken, resetExpiresAt, user.id)\n .run();\n\n const updated = await getUserByEmail(email);\n if (!updated) return { ok: false, reason: \"not_found\" };\n return { ok: true, token: resetToken, user: updated };\n}\n\nexport async function resetPasswordWithToken(input: {\n token: string;\n password: string;\n}): Promise<\n | { ok: true; user: User }\n | { ok: false; reason: \"invalid\" }\n> {\n const user = await getDatabase().prepare(\n `SELECT * FROM users WHERE password_reset_token = ?`\n )\n .bind(input.token)\n .first<User>();\n\n if (!user || !user.password_reset_expires_at) {\n return { ok: false, reason: \"invalid\" };\n }\n if (new Date(user.password_reset_expires_at).getTime() < Date.now()) {\n return { ok: false, reason: \"invalid\" };\n }\n\n const passwordHash = await hashPassword(input.password);\n await getDatabase().prepare(\n `UPDATE users\n SET password_hash = ?,\n password_reset_token = NULL,\n password_reset_expires_at = NULL,\n session_rev = session_rev + 1,\n last_seen_at = datetime('now')\n WHERE id = ?`\n )\n .bind(passwordHash, user.id)\n .run();\n\n const updated = await getUserById(user.id);\n if (!updated) return { ok: false, reason: \"invalid\" };\n return { ok: true, user: updated };\n}\n\nexport async function changeUserPassword(input: {\n userId: number;\n currentPassword: string;\n newPassword: string;\n}): Promise<\n | { ok: true; user: User }\n | { ok: false; reason: \"invalid\" | \"no_password\" }\n> {\n const user = await getUserById(input.userId);\n if (!user || !user.password_hash) {\n return { ok: false, reason: \"no_password\" };\n }\n\n const matches = await verifyPassword(\n input.currentPassword,\n user.password_hash\n );\n if (!matches) {\n return { ok: false, reason: \"invalid\" };\n }\n\n const passwordHash = await hashPassword(input.newPassword);\n await getDatabase().prepare(\n `UPDATE users\n SET password_hash = ?,\n session_rev = session_rev + 1,\n last_seen_at = datetime('now')\n WHERE id = ?`\n )\n .bind(passwordHash, user.id)\n .run();\n\n const updated = await getUserById(user.id);\n if (!updated) return { ok: false, reason: \"invalid\" };\n return { ok: true, user: updated };\n}\n\nexport async function authenticateEmailUser(input: {\n email: string;\n password: string;\n}): Promise<\n | { ok: true; user: User }\n | { ok: false; reason: \"invalid\" | \"unverified\" }\n> {\n const email = normalizeEmail(input.email);\n const user = await getUserByEmail(email);\n if (!user || !user.password_hash) {\n return { ok: false, reason: \"invalid\" };\n }\n\n const matches = await verifyPassword(input.password, user.password_hash);\n if (!matches) {\n return { ok: false, reason: \"invalid\" };\n }\n if (!user.email_verified) {\n return { ok: false, reason: \"unverified\" };\n }\n\n await getDatabase().prepare(\n `UPDATE users SET last_seen_at = datetime('now') WHERE id = ?`\n )\n .bind(user.id)\n .run();\n\n return { ok: true, user: { ...user, email } };\n}\n\nexport async function getUserByEmail(email: string): Promise<User | null> {\n return await getDatabase().prepare(`SELECT * FROM users WHERE email = ?`)\n .bind(normalizeEmail(email))\n .first<User>();\n}\n\nexport async function getUserById(id: number): Promise<User | null> {\n return await getDatabase().prepare(`SELECT * FROM users WHERE id = ?`)\n .bind(id)\n .first<User>();\n}\n\nexport async function listUsers(limit = 100): Promise<User[]> {\n const { results } = await getDatabase().prepare(\n `SELECT * FROM users ORDER BY created_at DESC LIMIT ?`\n )\n .bind(limit)\n .all<User>();\n return results || [];\n}\n\nexport async function listUsersWithPostCounts(\n limit = 100\n): Promise<UserListItem[]> {\n const { results } = await getDatabase().prepare(\n `SELECT u.*,\n (SELECT COUNT(*) FROM posts p WHERE p.owner_email = u.email) AS post_count\n FROM users u\n ORDER BY u.created_at DESC\n LIMIT ?`\n )\n .bind(limit)\n .all<UserListItem>();\n return results || [];\n}\n\n/** 递增 session_rev,使该用户所有已签发 cookie 立即失效。 */\nexport async function revokeUserSessions(userId: number): Promise<boolean> {\n const user = await getUserById(userId);\n if (!user) return false;\n await getDatabase().prepare(\n `UPDATE users SET session_rev = session_rev + 1 WHERE id = ?`\n )\n .bind(userId)\n .run();\n return true;\n}\n\nexport async function setUserRole(\n userId: number,\n role: Exclude<UserRole, \"admin\">\n): Promise<\n | { ok: true; user: User }\n | { ok: false; reason: \"not_found\" | \"is_admin\" }\n> {\n const user = await getUserById(userId);\n if (!user) return { ok: false, reason: \"not_found\" };\n if (await isAdminEmail(user.email)) {\n return { ok: false, reason: \"is_admin\" };\n }\n\n await getDatabase().prepare(\n `UPDATE users\n SET role = ?,\n last_seen_at = datetime('now')\n WHERE id = ?`\n )\n .bind(role, userId)\n .run();\n\n const updated = await getUserById(userId);\n if (!updated) return { ok: false, reason: \"not_found\" };\n return { ok: true, user: updated };\n}\n\nexport async function deleteUserAccount(userId: number): Promise<\n | { ok: true; email: string }\n | { ok: false; reason: \"not_found\" | \"is_admin\" }\n> {\n const user = await getUserById(userId);\n if (!user) return { ok: false, reason: \"not_found\" };\n if (await isAdminEmail(user.email)) {\n return { ok: false, reason: \"is_admin\" };\n }\n\n const settings = await getAppSettings();\n const adminEmail = settings.admin_email;\n\n const db = getDatabase();\n await db.batch([\n db.prepare(\n `UPDATE posts SET owner_email = ? WHERE owner_email = ?`\n ).bind(adminEmail, user.email),\n db.prepare(`DELETE FROM users WHERE id = ?`).bind(userId),\n ]);\n\n return { ok: true, email: user.email };\n}\n","// auth/user-session.ts - OAuth (Google) session cookie helpers.\n//\n// The session is an HMAC-SHA256-signed base64-encoded JSON payload\n// containing the user identity and an expiry. The HMAC key is the\n// `ADMIN_PASSWORD` secret so projects can reuse one Cloudflare secret\n// for both admin-password and OAuth cookies. The `session_rev` field\n// is incremented to invalidate every cookie for a user at once\n// (used on password reset, account delete, etc.).\n\nimport { cookies } from \"next/headers\";\nimport { getUserById } from \"./users\";\nimport { getDatabase } from \"../platform/current\";\nimport type { SessionUser } from \"./session\";\n\nconst SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days\nconst USER_COOKIE_NAME = \"vinext_user_session\";\nconst ADMIN_COOKIE_NAME = \"vinext_admin_session\";\n\n/** Cookie name holding the OAuth (Google) user session. */\nexport const USER_COOKIE = USER_COOKIE_NAME;\n\n/** Cookie name holding the admin-password session. */\nexport const ADMIN_COOKIE = ADMIN_COOKIE_NAME;\n\ninterface WorkerEnvLike {\n ADMIN_PASSWORD?: string;\n}\n\nfunction getAdminPassword(): string {\n // Prefer the platform env (Cloudflare / vite dev with .dev.vars). Falls\n // back to process.env for environments where neither is configured.\n // The fallback value is intentionally weak; production deployments\n // must set ADMIN_PASSWORD via `wrangler secret put`.\n let fromWorker: string | undefined;\n try {\n // The optional import keeps this module usable in unit tests that\n // do not have the `cloudflare:workers` virtual module.\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const mod = require(\"cloudflare:workers\") as { env?: WorkerEnvLike };\n fromWorker = mod.env?.ADMIN_PASSWORD;\n } catch {\n fromWorker = undefined;\n }\n if (fromWorker) return fromWorker;\n return process.env.ADMIN_PASSWORD ?? \"vinext-admin-2026\";\n}\n\nasync function hmac(secret: string, message: string): Promise<string> {\n const enc = new TextEncoder();\n const key = await crypto.subtle.importKey(\n \"raw\",\n enc.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"]\n );\n const sig = await crypto.subtle.sign(\"HMAC\", key, enc.encode(message));\n return [...new Uint8Array(sig)]\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\nasync function constantTimeEqual(a: string, b: string): Promise<boolean> {\n const aBytes = new TextEncoder().encode(a);\n const bBytes = new TextEncoder().encode(b);\n if (aBytes.length !== bBytes.length) return false;\n let diff = 0;\n for (let i = 0; i < aBytes.length; i++) {\n diff |= aBytes[i]! ^ bBytes[i]!;\n }\n return diff === 0;\n}\n\nasync function signPayload(payload: string): Promise<string> {\n return hmac(getAdminPassword(), payload);\n}\n\nfunction utf8ToBase64(s: string): string {\n const bytes = new TextEncoder().encode(s);\n let bin = \"\";\n for (const b of bytes) bin += String.fromCharCode(b);\n return btoa(bin);\n}\n\nfunction base64ToUtf8(b64: string): string {\n const bin = atob(b64);\n const bytes = new Uint8Array(bin.length);\n for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);\n return new TextDecoder().decode(bytes);\n}\n\n/** 给 OAuth 用户发 token:HMAC 签名 + base64-encoded JSON payload */\nexport async function signUserToken(user: SessionUser): Promise<string> {\n const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;\n const payload = { ...user, exp };\n const json = JSON.stringify(payload);\n const b64 = utf8ToBase64(json);\n const sig = await signPayload(b64);\n return `${b64}.${sig}`;\n}\n\nexport async function setUserSessionCookie(user: SessionUser) {\n const token = await signUserToken(user);\n const jar = await cookies();\n jar.set(USER_COOKIE_NAME, token, {\n httpOnly: true,\n secure: process.env.NODE_ENV === \"production\",\n sameSite: \"lax\",\n path: \"/\",\n maxAge: SESSION_TTL_SECONDS,\n });\n}\n\n/** 校验用户 session token:签名、过期、session_rev 是否与数据库一致 */\nexport async function verifyUserToken(\n token: string | undefined\n): Promise<SessionUser | null> {\n if (!token) return null;\n const parts = token.split(\".\");\n if (parts.length !== 2) return null;\n const [b64, sig] = parts;\n const expected = await signPayload(b64!);\n if (!(await constantTimeEqual(sig!, expected))) return null;\n try {\n const json = base64ToUtf8(b64!);\n const payload = JSON.parse(json) as SessionUser & { exp: number };\n if (payload.exp < Math.floor(Date.now() / 1000)) return null;\n\n const dbUser = await getUserById(payload.uid);\n if (!dbUser) return null;\n if (dbUser.email !== payload.email) return null;\n const tokenRev = payload.rev ?? 0;\n const dbRev = dbUser.session_rev ?? 0;\n if (tokenRev !== dbRev) return null;\n\n return {\n uid: payload.uid,\n email: payload.email,\n name: payload.name,\n picture: payload.picture,\n rev: dbRev,\n };\n } catch {\n return null;\n }\n}\n\n/** 当前 OAuth 用户(如果登录了),admin 密码登录时返 null */\nexport async function getCurrentUser(): Promise<SessionUser | null> {\n const jar = await cookies();\n const token = jar.get(USER_COOKIE_NAME)?.value;\n return verifyUserToken(token);\n}\n\nexport async function clearUserSessionCookie() {\n const jar = await cookies();\n jar.set(USER_COOKIE_NAME, \"\", { path: \"/\", maxAge: 0 });\n}\n\n// ====== Admin-password session ======\n\nasync function signAdminToken(): Promise<string> {\n const password = getAdminPassword();\n const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;\n const payload = `ok.${exp}`;\n const sig = await hmac(password, payload);\n return `${payload}.${sig}`;\n}\n\nasync function verifyAdminToken(token: string | undefined): Promise<boolean> {\n if (!token) return false;\n const parts = token.split(\".\");\n if (parts.length !== 3) return false;\n const [flag, expStr, sig] = parts;\n if (flag !== \"ok\") return false;\n const exp = Number(expStr);\n if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return false;\n const password = getAdminPassword();\n const expected = await hmac(password, `${flag}.${exp}`);\n return constantTimeEqual(sig!, expected);\n}\n\nexport async function checkPassword(input: string): Promise<boolean> {\n const expected = getAdminPassword();\n return constantTimeEqual(input, expected);\n}\n\nexport async function setSessionCookie() {\n const token = await signAdminToken();\n const jar = await cookies();\n jar.set(ADMIN_COOKIE_NAME, token, {\n httpOnly: true,\n secure: process.env.NODE_ENV === \"production\",\n sameSite: \"lax\",\n path: \"/\",\n maxAge: SESSION_TTL_SECONDS,\n });\n}\n\nexport async function clearSessionCookie() {\n const jar = await cookies();\n jar.set(ADMIN_COOKIE_NAME, \"\", { path: \"/\", maxAge: 0 });\n}\n\nfunction getAdminEmailFromEnv(): string {\n let fromWorker: string | undefined;\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const mod = require(\"cloudflare:workers\") as { env?: { ADMIN_EMAIL?: string } };\n fromWorker = mod.env?.ADMIN_EMAIL;\n } catch {\n fromWorker = undefined;\n }\n return (fromWorker || \"zhaofilms@gmail.com\").toLowerCase();\n}\n\nexport type AuthViewer = {\n email: string;\n user: SessionUser | null;\n role: \"user\" | \"vip\" | \"admin\";\n isAdmin: boolean;\n isVip: boolean;\n canViewVipContent: boolean;\n};\n\n/**\n * Combined viewer: returns an `AuthViewer` for the current request\n * regardless of which login method (admin password vs OAuth) the\n * user used. `null` means the request is unauthenticated.\n */\nexport async function getAuthViewer(): Promise<AuthViewer | null> {\n const jar = await cookies();\n\n if (await verifyAdminToken(jar.get(ADMIN_COOKIE_NAME)?.value)) {\n return {\n email: getAdminEmailFromEnv(),\n user: null,\n role: \"admin\",\n isAdmin: true,\n isVip: true,\n canViewVipContent: true,\n };\n }\n\n const user = await verifyUserToken(jar.get(USER_COOKIE_NAME)?.value);\n if (!user) return null;\n\n const dbUser = await getUserById(user.uid);\n if (!dbUser) return null;\n const role: \"user\" | \"vip\" | \"admin\" =\n dbUser.role === \"admin\" || dbUser.role === \"vip\" ? dbUser.role : \"user\";\n const isAdmin = role === \"admin\";\n const isVip = role === \"vip\" || isAdmin;\n\n return {\n email: user.email,\n user,\n role,\n isAdmin,\n isVip,\n canViewVipContent: isVip,\n };\n}\n\n/**\n * Extended isAuthenticated: admin password login or OAuth login both\n * count as authenticated.\n */\nexport async function isAuthenticated(): Promise<boolean> {\n const jar = await cookies();\n if (await verifyAdminToken(jar.get(ADMIN_COOKIE_NAME)?.value)) return true;\n if (await verifyUserToken(jar.get(USER_COOKIE_NAME)?.value)) return true;\n return false;\n}\n\n// Re-export the database helper to keep callers from having to reach\n// into the platform layer just to update session_rev after a reset.\nexport { getDatabase };\n"],"mappings":"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","names":["cookies","cache","env","options","getRuntimePlatform","normalizeEmail","normalizeEmail","normalizeEmail","cookies"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/auth/routes/google.ts","../../../src/internal/admin/settings.ts","../../../src/util/env.ts","../../../src/platform/runtime.ts","../../../src/platform/cloudflare-runtime.ts","../../../src/platform/current.ts"],"sourcesContent":["// auth/routes/google.ts\n//\n// GET /api/auth/google — kick off the Google OAuth flow:\n// 1. Generate a random `state` (CSRF protection).\n// 2. Save it to a short-lived cookie.\n// 3. 302 redirect to Google's consent screen.\n//\n// The Google OAuth client id/secret are read from the admin-configured\n// `app_settings` row, not from environment variables, so admins can\n// enable/disable the flow at runtime.\n\nimport { NextResponse } from \"next/server\";\nimport { getGoogleOAuthConfig } from \"../../internal/admin/settings\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst STATE_COOKIE = \"vinext_oauth_state\";\n\nexport async function GET(request: Request) {\n const config = await getGoogleOAuthConfig();\n if (!config) {\n return new NextResponse(\n \"Google OAuth not configured. Enable it in admin /admin/settings.\",\n { status: 503 }\n );\n }\n\n const state = [...crypto.getRandomValues(new Uint8Array(24))]\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n\n const url = new URL(request.url);\n const origin = `${url.protocol}//${url.host}`;\n const redirectUri = `${origin}/api/auth/google/callback`;\n\n const googleAuthUrl = new URL(\"https://accounts.google.com/o/oauth2/v2/auth\");\n googleAuthUrl.searchParams.set(\"client_id\", config.clientId);\n googleAuthUrl.searchParams.set(\"redirect_uri\", redirectUri);\n googleAuthUrl.searchParams.set(\"response_type\", \"code\");\n googleAuthUrl.searchParams.set(\"scope\", \"openid email profile\");\n googleAuthUrl.searchParams.set(\"state\", state);\n googleAuthUrl.searchParams.set(\"access_type\", \"offline\");\n googleAuthUrl.searchParams.set(\"prompt\", \"consent\");\n\n const res = NextResponse.redirect(googleAuthUrl.toString());\n res.cookies.set(STATE_COOKIE, state, {\n httpOnly: true,\n secure: url.protocol === \"https:\",\n sameSite: \"lax\",\n path: \"/\",\n maxAge: 600,\n });\n return res;\n}\n","// lib/settings.ts - 读取和更新后台系统设置(单管理员模型)\n// 数据保存在 SQL 表 app_settings,目前固定为 1 行。\n//\n// Internal to the package — not exposed via package.json exports. The\n// auth helpers (turnstile.ts, users.ts) call into the read functions;\n// admin pages in the starter import the update functions through a\n// re-export shim at `apps/moviebluebook/lib/settings.ts`.\n\nimport { cache } from \"react\";\nimport { workerEnv } from \"../../util/env\";\nimport { getDatabase } from \"../../platform/current\";\nimport {\n buildTurnstilePublicConfig,\n DEFAULT_TURNSTILE_PUBLIC_CONFIG,\n isSchemaDriftError,\n} from \"./schema-guard\";\n\nexport type AppSettings = {\n site_title: string;\n google_enabled: 0 | 1;\n google_client_id: string | null;\n google_client_secret: string | null;\n google_updated_at: string | null;\n turnstile_enabled: 0 | 1;\n turnstile_site_key: string | null;\n turnstile_updated_at: string | null;\n admin_email: string;\n updated_at: string;\n};\n\ntype Row = {\n site_title: string;\n google_enabled: number;\n google_client_id: string | null;\n google_client_secret: string | null;\n google_updated_at: string | null;\n turnstile_enabled: number;\n turnstile_site_key: string | null;\n turnstile_updated_at: string | null;\n admin_email: string;\n updated_at: string;\n};\n\nconst DEFAULT_ADMIN_EMAIL = \"zhaofilms@gmail.com\";\n\nfunction rowToSettings(r: Row): AppSettings {\n return {\n site_title: r.site_title,\n google_enabled: r.google_enabled === 1 ? 1 : 0,\n google_client_id: r.google_client_id,\n google_client_secret: r.google_client_secret,\n google_updated_at: r.google_updated_at,\n turnstile_enabled: r.turnstile_enabled === 1 ? 1 : 0,\n turnstile_site_key: r.turnstile_site_key,\n turnstile_updated_at: r.turnstile_updated_at,\n admin_email: r.admin_email,\n updated_at: r.updated_at,\n };\n}\n\nconst getAppSettingsCached = cache(async (): Promise<AppSettings> => {\n const row = await getDatabase().prepare(\n `SELECT site_title, google_enabled, google_client_id, google_client_secret,\n google_updated_at, turnstile_enabled, turnstile_site_key,\n turnstile_updated_at, admin_email, updated_at\n FROM app_settings WHERE id = 1`\n ).first<Row>();\n if (!row) {\n // 极端情况:迁移未执行\n return {\n site_title: \"vinext Blog\",\n google_enabled: 0,\n google_client_id: null,\n google_client_secret: null,\n google_updated_at: null,\n turnstile_enabled: 0,\n turnstile_site_key: null,\n turnstile_updated_at: null,\n admin_email: DEFAULT_ADMIN_EMAIL,\n updated_at: \"\",\n };\n }\n return rowToSettings(row);\n});\n\nexport async function getAppSettings(): Promise<AppSettings> {\n return getAppSettingsCached();\n}\n\n/** Turnstile 前端可见配置(site key 公开;secret 在 env)。 */\nexport async function getTurnstilePublicConfig(): Promise<{\n enabled: boolean;\n siteKey: string | null;\n secretConfigured: boolean;\n}> {\n try {\n const s = await getAppSettings();\n return buildTurnstilePublicConfig(s, workerEnv);\n } catch (error) {\n if (isSchemaDriftError(error)) {\n console.error(\n \"[settings] turnstile config unavailable due to schema drift; falling back to disabled state\",\n error\n );\n return { ...DEFAULT_TURNSTILE_PUBLIC_CONFIG };\n }\n throw error;\n }\n}\n\nexport async function updateTurnstileConfig(input: {\n enabled: boolean;\n siteKey: string;\n}): Promise<void> {\n const enabled = input.enabled ? 1 : 0;\n await getDatabase().prepare(\n `UPDATE app_settings\n SET turnstile_enabled = ?,\n turnstile_site_key = ?,\n turnstile_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n )\n .bind(enabled, input.siteKey || null)\n .run();\n}\n\nexport async function disableTurnstileConfig(): Promise<void> {\n await getDatabase().prepare(\n `UPDATE app_settings\n SET turnstile_enabled = 0,\n turnstile_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n ).run();\n}\n\n/** Google 登录实际配置:只有 enabled 且 id+secret 都存在才认为可用 */\nexport async function getGoogleOAuthConfig(): Promise<{\n enabled: boolean;\n clientId: string;\n clientSecret: string;\n} | null> {\n const s = await getAppSettings();\n if (!s.google_enabled) return null;\n if (!s.google_client_id || !s.google_client_secret) return null;\n return {\n enabled: true,\n clientId: s.google_client_id,\n clientSecret: s.google_client_secret,\n };\n}\n\nexport async function updateGoogleOAuthConfig(input: {\n enabled: boolean;\n clientId: string;\n clientSecret: string;\n}): Promise<void> {\n const enabled = input.enabled ? 1 : 0;\n await getDatabase().prepare(\n `UPDATE app_settings\n SET google_enabled = ?,\n google_client_id = ?,\n google_client_secret = ?,\n google_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n )\n .bind(enabled, input.clientId, input.clientSecret)\n .run();\n}\n\nexport async function clearGoogleOAuthConfig(): Promise<void> {\n await getDatabase().prepare(\n `UPDATE app_settings\n SET google_enabled = 0,\n google_client_id = NULL,\n google_client_secret = NULL,\n google_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n ).run();\n}\n\nexport async function updateSiteTitle(title: string): Promise<void> {\n await getDatabase().prepare(\n `UPDATE app_settings SET site_title = ?, updated_at = datetime('now') WHERE id = 1`\n )\n .bind(title)\n .run();\n}\n","// lib/env.ts - 集中获取 Cloudflare bindings\n// 用 cloudflare:workers 模块(workerd 内置),作为平台 adapter 的绑定入口\n\n/// <reference types=\"@cloudflare/workers-types\" />\nimport { env } from \"cloudflare:workers\";\n\nexport type AppEnv = {\n DB: D1Database;\n ASSETS: Fetcher;\n IMAGES: ImagesBinding;\n ASSETS_BUCKET?: R2Bucket;\n CONTENT_CACHE?: KVNamespace;\n ADMIN_PASSWORD: string;\n ADMIN_EMAIL?: string;\n SITE_URL?: string;\n RESEND_API_KEY?: string;\n RESEND_FROM?: string;\n // Google OAuth 仍然兼容 Cloudflare Secret 作为兜底。\n // 实际生效值以 app_settings.google_client_id / google_client_secret 为准。\n GOOGLE_CLIENT_ID?: string;\n GOOGLE_CLIENT_SECRET?: string;\n /** Turnstile site key fallback when not stored in app_settings */\n TURNSTILE_SITE_KEY?: string;\n /** Turnstile secret — set via `wrangler secret put TURNSTILE_SECRET_KEY` */\n TURNSTILE_SECRET_KEY?: string;\n /** Notion integration token for the blog data source */\n NOTION_TOKEN?: string;\n /** Notion data source ID used by dataSources.query */\n NOTION_DATA_SOURCE_ID?: string;\n /** Notion data source ID for the public movie catalog */\n NOTION_MOVIES_DATA_SOURCE_ID?: string;\n /** Notion data source ID for localized movie copy */\n NOTION_MOVIE_TRANSLATIONS_DATA_SOURCE_ID?: string;\n /** Optional Notion API base URL for tests or proxies */\n NOTION_API_BASE_URL?: string;\n /** Optional Notion edit URL for admin handoff screens */\n NOTION_EDIT_BASE_URL?: string;\n /** Optional webhook verification token for Notion invalidation */\n NOTION_WEBHOOK_VERIFICATION_TOKEN?: string;\n};\n\n// 强制类型:vinext 把 env 类型放在 env.d.ts(interface VinextEnv extends Env),\n// 但 TS server 经常解析不到。运行时一定有 DB,类型断言保证编译通过。\nexport const workerEnv = env as unknown as AppEnv;\n","import type { AppEnv } from \"../util/env\";\n\nexport type PlatformBindingEnv = Pick<\n AppEnv,\n \"ASSETS_BUCKET\" | \"CONTENT_CACHE\" | \"DB\" | \"IMAGES\"\n>;\n\nexport type StoredObject = {\n body: ReadableStream;\n size: number;\n etag?: string;\n contentType?: string;\n};\n\nexport type ObjectStoragePutOptions = {\n contentType?: string;\n cacheControl?: string;\n metadata?: Record<string, string>;\n};\n\nexport type ObjectStorageListItem = {\n key: string;\n size: number;\n uploaded: Date;\n};\n\nexport type ObjectStorageAdapter = {\n kind: \"r2\";\n get(key: string): Promise<StoredObject | null>;\n put(\n key: string,\n value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob,\n options?: ObjectStoragePutOptions\n ): Promise<void>;\n delete(key: string): Promise<void>;\n list(\n options?: { prefix?: string; limit?: number }\n ): Promise<ObjectStorageListItem[]>;\n};\n\nexport type ImageTransformOptions = {\n width?: number;\n format: \"image/avif\" | \"image/webp\";\n quality: number;\n};\n\nexport type ImageTransformResult = {\n body: ReadableStream;\n contentType: string;\n response(): Response;\n};\n\nexport type ImageTransformerAdapter = {\n kind: \"cloudflare-images\" | \"external\";\n transform(\n body: ReadableStream,\n options: ImageTransformOptions\n ): Promise<ImageTransformResult>;\n};\n\nexport type PublicCacheAdapter = {\n kind: \"cloudflare-cache\" | \"noop\" | \"external\";\n match(key: string): Promise<Response | null>;\n put(key: string, response: Response): Promise<void>;\n delete(key: string): Promise<boolean>;\n};\n\nexport type KeyValueCacheGetOptions = {\n cacheTtl?: number;\n};\n\nexport type KeyValueCachePutOptions = {\n expirationTtl?: number;\n metadata?: Record<string, string | number | boolean | null>;\n};\n\nexport type KeyValueCacheListOptions = {\n prefix?: string;\n limit?: number;\n cursor?: string;\n};\n\nexport type KeyValueCacheListResult = {\n keys: Array<{ name: string }>;\n cursor?: string;\n listComplete: boolean;\n};\n\nexport type KeyValueCacheAdapter = {\n kind: \"workers-kv\" | \"noop\" | \"external\";\n get<T = unknown>(\n key: string,\n options?: KeyValueCacheGetOptions\n ): Promise<T | null>;\n put<T = unknown>(\n key: string,\n value: T,\n options?: KeyValueCachePutOptions\n ): Promise<void>;\n delete(key: string): Promise<void>;\n list(options?: KeyValueCacheListOptions): Promise<KeyValueCacheListResult>;\n};\n\nexport type SqlValue = string | number | boolean | null;\n\nexport type SqlResult<T = Record<string, unknown>> = {\n results?: T[];\n success?: boolean;\n meta?: {\n changes?: number;\n duration?: number;\n last_row_id?: number;\n rows_read?: number;\n rows_written?: number;\n [key: string]: unknown;\n };\n};\n\nexport type SqlPreparedStatement = {\n bind(...values: SqlValue[]): SqlPreparedStatement;\n all<T = Record<string, unknown>>(): Promise<SqlResult<T>>;\n first<T = Record<string, unknown>>(columnName?: string): Promise<T | null>;\n run<T = Record<string, unknown>>(): Promise<SqlResult<T>>;\n};\n\nexport type SqlDatabaseAdapter = {\n kind: \"d1\";\n prepare(query: string): SqlPreparedStatement;\n batch<T = Record<string, unknown>>(\n statements: SqlPreparedStatement[]\n ): Promise<SqlResult<T>[]>;\n};\n\nexport type RuntimePlatform = {\n id: \"cloudflare-workers\";\n database: SqlDatabaseAdapter | null;\n objectStorage: ObjectStorageAdapter | null;\n imageTransformer: ImageTransformerAdapter | null;\n publicCache: PublicCacheAdapter | null;\n keyValueCache: KeyValueCacheAdapter | null;\n};\n\ntype CloudflareCacheLike = Pick<Cache, \"match\" | \"put\" | \"delete\">;\ntype CloudflareKvLike = Pick<KVNamespace, \"get\" | \"put\" | \"delete\" | \"list\">;\n\nfunction cacheRequestForKey(key: string) {\n return new Request(key, { method: \"GET\" });\n}\n\nexport function createCloudflarePublicCacheAdapter(\n cache: CloudflareCacheLike\n): PublicCacheAdapter {\n return {\n kind: \"cloudflare-cache\",\n async match(key) {\n return (await cache.match(cacheRequestForKey(key))) ?? null;\n },\n put(key, response) {\n return cache.put(cacheRequestForKey(key), response);\n },\n delete(key) {\n return cache.delete(cacheRequestForKey(key));\n },\n };\n}\n\nexport function createNoopPublicCacheAdapter(kind: \"noop\" = \"noop\"): PublicCacheAdapter {\n return {\n kind,\n async match() {\n return null;\n },\n async put() {},\n async delete() {\n return false;\n },\n };\n}\n\nexport function createCloudflareKeyValueCacheAdapter(\n namespace: CloudflareKvLike\n): KeyValueCacheAdapter {\n return {\n kind: \"workers-kv\",\n async get<T = unknown>(\n key: string,\n options?: KeyValueCacheGetOptions\n ): Promise<T | null> {\n return (await namespace.get(key, {\n type: \"json\",\n cacheTtl: options?.cacheTtl,\n })) as T | null;\n },\n async put(key, value, options) {\n await namespace.put(key, JSON.stringify(value), {\n expirationTtl: options?.expirationTtl,\n metadata: options?.metadata,\n });\n },\n delete(key) {\n return namespace.delete(key);\n },\n async list(options) {\n const result = await namespace.list({\n prefix: options?.prefix,\n limit: options?.limit,\n cursor: options?.cursor,\n });\n return {\n keys: result.keys.map((key) => ({ name: key.name })),\n cursor: result.list_complete ? undefined : result.cursor,\n listComplete: result.list_complete,\n };\n },\n };\n}\n\nexport function createNoopKeyValueCacheAdapter(\n kind: \"noop\" = \"noop\"\n): KeyValueCacheAdapter {\n return {\n kind,\n async get() {\n return null;\n },\n async put() {},\n async delete() {},\n async list() {\n return { keys: [], listComplete: true };\n },\n };\n}\n\nfunction r2ObjectToStoredObject(object: R2ObjectBody): StoredObject {\n return {\n body: object.body,\n size: object.size,\n etag: object.etag,\n contentType: object.httpMetadata?.contentType,\n };\n}\n\nexport function createCloudflareRuntimePlatform(\n env: PlatformBindingEnv,\n options?: { publicCache?: CloudflareCacheLike | null }\n): RuntimePlatform {\n const database: SqlDatabaseAdapter | null = env.DB\n ? ({\n kind: \"d1\",\n prepare(query: string) {\n return env.DB.prepare(query) as unknown as SqlPreparedStatement;\n },\n async batch(statements: SqlPreparedStatement[]) {\n return (await env.DB.batch(\n statements as unknown as D1PreparedStatement[]\n )) as unknown as SqlResult<Record<string, unknown>>[];\n },\n } as unknown as SqlDatabaseAdapter)\n : null;\n\n const objectStorage: ObjectStorageAdapter | null = env.ASSETS_BUCKET\n ? {\n kind: \"r2\",\n async get(key) {\n const object = await env.ASSETS_BUCKET?.get(key);\n return object ? r2ObjectToStoredObject(object) : null;\n },\n async put(key, value, options) {\n await env.ASSETS_BUCKET?.put(key, value, {\n httpMetadata: {\n contentType: options?.contentType,\n cacheControl: options?.cacheControl,\n },\n customMetadata: options?.metadata,\n });\n },\n async delete(key) {\n await env.ASSETS_BUCKET?.delete(key);\n },\n async list(options) {\n const listed = await env.ASSETS_BUCKET?.list({\n prefix: options?.prefix,\n limit: options?.limit,\n });\n return (\n listed?.objects.map((object) => ({\n key: object.key,\n size: object.size,\n uploaded: object.uploaded,\n })) ?? []\n );\n },\n }\n : null;\n\n const imageTransformer: ImageTransformerAdapter | null = env.IMAGES\n ? {\n kind: \"cloudflare-images\",\n async transform(body, options) {\n const result = await env.IMAGES.input(body)\n .transform(options.width ? { width: options.width } : {})\n .output({\n format: options.format,\n quality: options.quality,\n });\n return {\n body: result.image(),\n contentType: result.contentType(),\n response: () => result.response(),\n };\n },\n }\n : null;\n\n const keyValueCache: KeyValueCacheAdapter | null = env.CONTENT_CACHE\n ? createCloudflareKeyValueCacheAdapter(env.CONTENT_CACHE)\n : null;\n\n return {\n id: \"cloudflare-workers\",\n database,\n objectStorage,\n imageTransformer,\n keyValueCache,\n publicCache: options?.publicCache\n ? createCloudflarePublicCacheAdapter(options.publicCache)\n : null,\n };\n}\n","import { workerEnv } from \"../util/env\";\nimport {\n createCloudflarePublicCacheAdapter,\n createCloudflareRuntimePlatform,\n} from \"./runtime\";\n\nfunction getDefaultCloudflareCache() {\n const globalWithCaches = globalThis as typeof globalThis & {\n caches?: CacheStorage & { default?: Cache };\n };\n return globalWithCaches.caches?.default ?? null;\n}\n\nexport function getRuntimePlatform() {\n return createCloudflareRuntimePlatform(workerEnv, {\n publicCache: getDefaultCloudflareCache(),\n });\n}\n\nexport function getDatabase() {\n const database = getRuntimePlatform().database;\n if (!database) {\n throw new Error(\"SQL database binding not configured\");\n }\n return database;\n}\n\nexport function getPublicCache() {\n const cache = getDefaultCloudflareCache();\n if (!cache) {\n throw new Error(\"Cloudflare cache binding not configured\");\n }\n return createCloudflarePublicCacheAdapter(cache);\n}\n","import {\n getPublicCache as getCloudflarePublicCache,\n getRuntimePlatform as getCloudflareRuntimePlatform,\n} from \"./cloudflare-runtime\";\nimport { currentRuntimeId } from \"./selection\";\n\nexport function getRuntimePlatform() {\n return getCloudflareRuntimePlatform();\n}\n\nexport function getDatabase() {\n const platform = getRuntimePlatform();\n const database = platform.database;\n if (!database) {\n throw new Error(`SQL database adapter not configured for ${platform.id}`);\n }\n return database;\n}\n\nexport function getPublicCache() {\n return getCloudflarePublicCache();\n}\n\nexport function getKeyValueCache() {\n return getRuntimePlatform().keyValueCache;\n}\n\nexport const runtimeSelection = {\n currentRuntimeId,\n};\n"],"mappings":"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","names":["cache","env","options","getRuntimePlatform"]}
|
|
1
|
+
{"version":3,"sources":["../../../src/auth/routes/google.ts","../../../src/internal/admin/settings.ts","../../../src/util/env.ts","../../../src/platform/runtime.ts","../../../src/platform/cloudflare-runtime.ts","../../../src/platform/current.ts"],"sourcesContent":["// auth/routes/google.ts\n//\n// GET /api/auth/google — kick off the Google OAuth flow:\n// 1. Generate a random `state` (CSRF protection).\n// 2. Save it to a short-lived cookie.\n// 3. 302 redirect to Google's consent screen.\n//\n// The Google OAuth client id/secret are read from the admin-configured\n// `app_settings` row, not from environment variables, so admins can\n// enable/disable the flow at runtime.\n\nimport { NextResponse } from \"next/server\";\nimport { getGoogleOAuthConfig } from \"../../internal/admin/settings\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst STATE_COOKIE = \"vinext_oauth_state\";\n\nexport async function GET(request: Request) {\n const config = await getGoogleOAuthConfig();\n if (!config) {\n return new NextResponse(\n \"Google OAuth not configured. Enable it in admin /admin/settings.\",\n { status: 503 }\n );\n }\n\n const state = [...crypto.getRandomValues(new Uint8Array(24))]\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n\n const url = new URL(request.url);\n const origin = `${url.protocol}//${url.host}`;\n const redirectUri = `${origin}/api/auth/google/callback`;\n\n const googleAuthUrl = new URL(\"https://accounts.google.com/o/oauth2/v2/auth\");\n googleAuthUrl.searchParams.set(\"client_id\", config.clientId);\n googleAuthUrl.searchParams.set(\"redirect_uri\", redirectUri);\n googleAuthUrl.searchParams.set(\"response_type\", \"code\");\n googleAuthUrl.searchParams.set(\"scope\", \"openid email profile\");\n googleAuthUrl.searchParams.set(\"state\", state);\n googleAuthUrl.searchParams.set(\"access_type\", \"offline\");\n googleAuthUrl.searchParams.set(\"prompt\", \"consent\");\n\n const res = NextResponse.redirect(googleAuthUrl.toString());\n res.cookies.set(STATE_COOKIE, state, {\n httpOnly: true,\n secure: url.protocol === \"https:\",\n sameSite: \"lax\",\n path: \"/\",\n maxAge: 600,\n });\n return res;\n}\n","// lib/settings.ts - 读取和更新后台系统设置(单管理员模型)\n// 数据保存在 SQL 表 app_settings,目前固定为 1 行。\n//\n// Internal to the package — not exposed via package.json exports. The\n// auth helpers (turnstile.ts, users.ts) call into the read functions;\n// consumer apps may re-export the update functions for local admin pages.\n\nimport { cache } from \"react\";\nimport { workerEnv } from \"../../util/env\";\nimport { getDatabase } from \"../../platform/current\";\nimport {\n buildTurnstilePublicConfig,\n DEFAULT_TURNSTILE_PUBLIC_CONFIG,\n isSchemaDriftError,\n} from \"./schema-guard\";\n\nexport type AppSettings = {\n site_title: string;\n google_enabled: 0 | 1;\n google_client_id: string | null;\n google_client_secret: string | null;\n google_updated_at: string | null;\n turnstile_enabled: 0 | 1;\n turnstile_site_key: string | null;\n turnstile_updated_at: string | null;\n admin_email: string;\n updated_at: string;\n};\n\ntype Row = {\n site_title: string;\n google_enabled: number;\n google_client_id: string | null;\n google_client_secret: string | null;\n google_updated_at: string | null;\n turnstile_enabled: number;\n turnstile_site_key: string | null;\n turnstile_updated_at: string | null;\n admin_email: string;\n updated_at: string;\n};\n\nconst DEFAULT_ADMIN_EMAIL = \"zhaofilms@gmail.com\";\n\nfunction rowToSettings(r: Row): AppSettings {\n return {\n site_title: r.site_title,\n google_enabled: r.google_enabled === 1 ? 1 : 0,\n google_client_id: r.google_client_id,\n google_client_secret: r.google_client_secret,\n google_updated_at: r.google_updated_at,\n turnstile_enabled: r.turnstile_enabled === 1 ? 1 : 0,\n turnstile_site_key: r.turnstile_site_key,\n turnstile_updated_at: r.turnstile_updated_at,\n admin_email: r.admin_email,\n updated_at: r.updated_at,\n };\n}\n\nconst getAppSettingsCached = cache(async (): Promise<AppSettings> => {\n const row = await getDatabase().prepare(\n `SELECT site_title, google_enabled, google_client_id, google_client_secret,\n google_updated_at, turnstile_enabled, turnstile_site_key,\n turnstile_updated_at, admin_email, updated_at\n FROM app_settings WHERE id = 1`\n ).first<Row>();\n if (!row) {\n // 极端情况:迁移未执行\n return {\n site_title: \"vinext Blog\",\n google_enabled: 0,\n google_client_id: null,\n google_client_secret: null,\n google_updated_at: null,\n turnstile_enabled: 0,\n turnstile_site_key: null,\n turnstile_updated_at: null,\n admin_email: DEFAULT_ADMIN_EMAIL,\n updated_at: \"\",\n };\n }\n return rowToSettings(row);\n});\n\nexport async function getAppSettings(): Promise<AppSettings> {\n return getAppSettingsCached();\n}\n\n/** Turnstile 前端可见配置(site key 公开;secret 在 env)。 */\nexport async function getTurnstilePublicConfig(): Promise<{\n enabled: boolean;\n siteKey: string | null;\n secretConfigured: boolean;\n}> {\n try {\n const s = await getAppSettings();\n return buildTurnstilePublicConfig(s, workerEnv);\n } catch (error) {\n if (isSchemaDriftError(error)) {\n console.error(\n \"[settings] turnstile config unavailable due to schema drift; falling back to disabled state\",\n error\n );\n return { ...DEFAULT_TURNSTILE_PUBLIC_CONFIG };\n }\n throw error;\n }\n}\n\nexport async function updateTurnstileConfig(input: {\n enabled: boolean;\n siteKey: string;\n}): Promise<void> {\n const enabled = input.enabled ? 1 : 0;\n await getDatabase().prepare(\n `UPDATE app_settings\n SET turnstile_enabled = ?,\n turnstile_site_key = ?,\n turnstile_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n )\n .bind(enabled, input.siteKey || null)\n .run();\n}\n\nexport async function disableTurnstileConfig(): Promise<void> {\n await getDatabase().prepare(\n `UPDATE app_settings\n SET turnstile_enabled = 0,\n turnstile_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n ).run();\n}\n\n/** Google 登录实际配置:只有 enabled 且 id+secret 都存在才认为可用 */\nexport async function getGoogleOAuthConfig(): Promise<{\n enabled: boolean;\n clientId: string;\n clientSecret: string;\n} | null> {\n const s = await getAppSettings();\n if (!s.google_enabled) return null;\n if (!s.google_client_id || !s.google_client_secret) return null;\n return {\n enabled: true,\n clientId: s.google_client_id,\n clientSecret: s.google_client_secret,\n };\n}\n\nexport async function updateGoogleOAuthConfig(input: {\n enabled: boolean;\n clientId: string;\n clientSecret: string;\n}): Promise<void> {\n const enabled = input.enabled ? 1 : 0;\n await getDatabase().prepare(\n `UPDATE app_settings\n SET google_enabled = ?,\n google_client_id = ?,\n google_client_secret = ?,\n google_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n )\n .bind(enabled, input.clientId, input.clientSecret)\n .run();\n}\n\nexport async function clearGoogleOAuthConfig(): Promise<void> {\n await getDatabase().prepare(\n `UPDATE app_settings\n SET google_enabled = 0,\n google_client_id = NULL,\n google_client_secret = NULL,\n google_updated_at = datetime('now'),\n updated_at = datetime('now')\n WHERE id = 1`\n ).run();\n}\n\nexport async function updateSiteTitle(title: string): Promise<void> {\n await getDatabase().prepare(\n `UPDATE app_settings SET site_title = ?, updated_at = datetime('now') WHERE id = 1`\n )\n .bind(title)\n .run();\n}\n","// lib/env.ts - 集中获取 Cloudflare bindings\n// 用 cloudflare:workers 模块(workerd 内置),作为平台 adapter 的绑定入口\n\n/// <reference types=\"@cloudflare/workers-types\" />\nimport { env } from \"cloudflare:workers\";\n\nexport type AppEnv = {\n DB: D1Database;\n ASSETS: Fetcher;\n IMAGES: ImagesBinding;\n ASSETS_BUCKET?: R2Bucket;\n CONTENT_CACHE?: KVNamespace;\n ADMIN_PASSWORD: string;\n ADMIN_EMAIL?: string;\n SITE_URL?: string;\n RESEND_API_KEY?: string;\n RESEND_FROM?: string;\n // Google OAuth 仍然兼容 Cloudflare Secret 作为兜底。\n // 实际生效值以 app_settings.google_client_id / google_client_secret 为准。\n GOOGLE_CLIENT_ID?: string;\n GOOGLE_CLIENT_SECRET?: string;\n /** Turnstile site key fallback when not stored in app_settings */\n TURNSTILE_SITE_KEY?: string;\n /** Turnstile secret — set via `wrangler secret put TURNSTILE_SECRET_KEY` */\n TURNSTILE_SECRET_KEY?: string;\n /** Notion integration token for the blog data source */\n NOTION_TOKEN?: string;\n /** Notion data source ID used by dataSources.query */\n NOTION_DATA_SOURCE_ID?: string;\n /** Optional Notion API base URL for tests or proxies */\n NOTION_API_BASE_URL?: string;\n /** Optional Notion edit URL for admin handoff screens */\n NOTION_EDIT_BASE_URL?: string;\n /** Optional webhook verification token for Notion invalidation */\n NOTION_WEBHOOK_VERIFICATION_TOKEN?: string;\n};\n\n// 强制类型:vinext 把 env 类型放在 env.d.ts(interface VinextEnv extends Env),\n// 但 TS server 经常解析不到。运行时一定有 DB,类型断言保证编译通过。\nexport const workerEnv = env as unknown as AppEnv;\n","import type { AppEnv } from \"../util/env\";\n\nexport type PlatformBindingEnv = Pick<\n AppEnv,\n \"ASSETS_BUCKET\" | \"CONTENT_CACHE\" | \"DB\" | \"IMAGES\"\n>;\n\nexport type StoredObject = {\n body: ReadableStream;\n size: number;\n etag?: string;\n contentType?: string;\n};\n\nexport type ObjectStoragePutOptions = {\n contentType?: string;\n cacheControl?: string;\n metadata?: Record<string, string>;\n};\n\nexport type ObjectStorageListItem = {\n key: string;\n size: number;\n uploaded: Date;\n};\n\nexport type ObjectStorageAdapter = {\n kind: \"r2\";\n get(key: string): Promise<StoredObject | null>;\n put(\n key: string,\n value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob,\n options?: ObjectStoragePutOptions\n ): Promise<void>;\n delete(key: string): Promise<void>;\n list(\n options?: { prefix?: string; limit?: number }\n ): Promise<ObjectStorageListItem[]>;\n};\n\nexport type ImageTransformOptions = {\n width?: number;\n format: \"image/avif\" | \"image/webp\";\n quality: number;\n};\n\nexport type ImageTransformResult = {\n body: ReadableStream;\n contentType: string;\n response(): Response;\n};\n\nexport type ImageTransformerAdapter = {\n kind: \"cloudflare-images\" | \"external\";\n transform(\n body: ReadableStream,\n options: ImageTransformOptions\n ): Promise<ImageTransformResult>;\n};\n\nexport type PublicCacheAdapter = {\n kind: \"cloudflare-cache\" | \"noop\" | \"external\";\n match(key: string): Promise<Response | null>;\n put(key: string, response: Response): Promise<void>;\n delete(key: string): Promise<boolean>;\n};\n\nexport type KeyValueCacheGetOptions = {\n cacheTtl?: number;\n};\n\nexport type KeyValueCachePutOptions = {\n expirationTtl?: number;\n metadata?: Record<string, string | number | boolean | null>;\n};\n\nexport type KeyValueCacheListOptions = {\n prefix?: string;\n limit?: number;\n cursor?: string;\n};\n\nexport type KeyValueCacheListResult = {\n keys: Array<{ name: string }>;\n cursor?: string;\n listComplete: boolean;\n};\n\nexport type KeyValueCacheAdapter = {\n kind: \"workers-kv\" | \"noop\" | \"external\";\n get<T = unknown>(\n key: string,\n options?: KeyValueCacheGetOptions\n ): Promise<T | null>;\n put<T = unknown>(\n key: string,\n value: T,\n options?: KeyValueCachePutOptions\n ): Promise<void>;\n delete(key: string): Promise<void>;\n list(options?: KeyValueCacheListOptions): Promise<KeyValueCacheListResult>;\n};\n\nexport type SqlValue = string | number | boolean | null;\n\nexport type SqlResult<T = Record<string, unknown>> = {\n results?: T[];\n success?: boolean;\n meta?: {\n changes?: number;\n duration?: number;\n last_row_id?: number;\n rows_read?: number;\n rows_written?: number;\n [key: string]: unknown;\n };\n};\n\nexport type SqlPreparedStatement = {\n bind(...values: SqlValue[]): SqlPreparedStatement;\n all<T = Record<string, unknown>>(): Promise<SqlResult<T>>;\n first<T = Record<string, unknown>>(columnName?: string): Promise<T | null>;\n run<T = Record<string, unknown>>(): Promise<SqlResult<T>>;\n};\n\nexport type SqlDatabaseAdapter = {\n kind: \"d1\";\n prepare(query: string): SqlPreparedStatement;\n batch<T = Record<string, unknown>>(\n statements: SqlPreparedStatement[]\n ): Promise<SqlResult<T>[]>;\n};\n\nexport type RuntimePlatform = {\n id: \"cloudflare-workers\";\n database: SqlDatabaseAdapter | null;\n objectStorage: ObjectStorageAdapter | null;\n imageTransformer: ImageTransformerAdapter | null;\n publicCache: PublicCacheAdapter | null;\n keyValueCache: KeyValueCacheAdapter | null;\n};\n\ntype CloudflareCacheLike = Pick<Cache, \"match\" | \"put\" | \"delete\">;\ntype CloudflareKvLike = Pick<KVNamespace, \"get\" | \"put\" | \"delete\" | \"list\">;\n\nfunction cacheRequestForKey(key: string) {\n return new Request(key, { method: \"GET\" });\n}\n\nexport function createCloudflarePublicCacheAdapter(\n cache: CloudflareCacheLike\n): PublicCacheAdapter {\n return {\n kind: \"cloudflare-cache\",\n async match(key) {\n return (await cache.match(cacheRequestForKey(key))) ?? null;\n },\n put(key, response) {\n return cache.put(cacheRequestForKey(key), response);\n },\n delete(key) {\n return cache.delete(cacheRequestForKey(key));\n },\n };\n}\n\nexport function createNoopPublicCacheAdapter(kind: \"noop\" = \"noop\"): PublicCacheAdapter {\n return {\n kind,\n async match() {\n return null;\n },\n async put() {},\n async delete() {\n return false;\n },\n };\n}\n\nexport function createCloudflareKeyValueCacheAdapter(\n namespace: CloudflareKvLike\n): KeyValueCacheAdapter {\n return {\n kind: \"workers-kv\",\n async get<T = unknown>(\n key: string,\n options?: KeyValueCacheGetOptions\n ): Promise<T | null> {\n return (await namespace.get(key, {\n type: \"json\",\n cacheTtl: options?.cacheTtl,\n })) as T | null;\n },\n async put(key, value, options) {\n await namespace.put(key, JSON.stringify(value), {\n expirationTtl: options?.expirationTtl,\n metadata: options?.metadata,\n });\n },\n delete(key) {\n return namespace.delete(key);\n },\n async list(options) {\n const result = await namespace.list({\n prefix: options?.prefix,\n limit: options?.limit,\n cursor: options?.cursor,\n });\n return {\n keys: result.keys.map((key) => ({ name: key.name })),\n cursor: result.list_complete ? undefined : result.cursor,\n listComplete: result.list_complete,\n };\n },\n };\n}\n\nexport function createNoopKeyValueCacheAdapter(\n kind: \"noop\" = \"noop\"\n): KeyValueCacheAdapter {\n return {\n kind,\n async get() {\n return null;\n },\n async put() {},\n async delete() {},\n async list() {\n return { keys: [], listComplete: true };\n },\n };\n}\n\nfunction r2ObjectToStoredObject(object: R2ObjectBody): StoredObject {\n return {\n body: object.body,\n size: object.size,\n etag: object.etag,\n contentType: object.httpMetadata?.contentType,\n };\n}\n\nexport function createCloudflareRuntimePlatform(\n env: PlatformBindingEnv,\n options?: { publicCache?: CloudflareCacheLike | null }\n): RuntimePlatform {\n const database: SqlDatabaseAdapter | null = env.DB\n ? ({\n kind: \"d1\",\n prepare(query: string) {\n return env.DB.prepare(query) as unknown as SqlPreparedStatement;\n },\n async batch(statements: SqlPreparedStatement[]) {\n return (await env.DB.batch(\n statements as unknown as D1PreparedStatement[]\n )) as unknown as SqlResult<Record<string, unknown>>[];\n },\n } as unknown as SqlDatabaseAdapter)\n : null;\n\n const objectStorage: ObjectStorageAdapter | null = env.ASSETS_BUCKET\n ? {\n kind: \"r2\",\n async get(key) {\n const object = await env.ASSETS_BUCKET?.get(key);\n return object ? r2ObjectToStoredObject(object) : null;\n },\n async put(key, value, options) {\n await env.ASSETS_BUCKET?.put(key, value, {\n httpMetadata: {\n contentType: options?.contentType,\n cacheControl: options?.cacheControl,\n },\n customMetadata: options?.metadata,\n });\n },\n async delete(key) {\n await env.ASSETS_BUCKET?.delete(key);\n },\n async list(options) {\n const listed = await env.ASSETS_BUCKET?.list({\n prefix: options?.prefix,\n limit: options?.limit,\n });\n return (\n listed?.objects.map((object) => ({\n key: object.key,\n size: object.size,\n uploaded: object.uploaded,\n })) ?? []\n );\n },\n }\n : null;\n\n const imageTransformer: ImageTransformerAdapter | null = env.IMAGES\n ? {\n kind: \"cloudflare-images\",\n async transform(body, options) {\n const result = await env.IMAGES.input(body)\n .transform(options.width ? { width: options.width } : {})\n .output({\n format: options.format,\n quality: options.quality,\n });\n return {\n body: result.image(),\n contentType: result.contentType(),\n response: () => result.response(),\n };\n },\n }\n : null;\n\n const keyValueCache: KeyValueCacheAdapter | null = env.CONTENT_CACHE\n ? createCloudflareKeyValueCacheAdapter(env.CONTENT_CACHE)\n : null;\n\n return {\n id: \"cloudflare-workers\",\n database,\n objectStorage,\n imageTransformer,\n keyValueCache,\n publicCache: options?.publicCache\n ? createCloudflarePublicCacheAdapter(options.publicCache)\n : null,\n };\n}\n","import { workerEnv } from \"../util/env\";\nimport {\n createCloudflarePublicCacheAdapter,\n createCloudflareRuntimePlatform,\n} from \"./runtime\";\n\nfunction getDefaultCloudflareCache() {\n const globalWithCaches = globalThis as typeof globalThis & {\n caches?: CacheStorage & { default?: Cache };\n };\n return globalWithCaches.caches?.default ?? null;\n}\n\nexport function getRuntimePlatform() {\n return createCloudflareRuntimePlatform(workerEnv, {\n publicCache: getDefaultCloudflareCache(),\n });\n}\n\nexport function getDatabase() {\n const database = getRuntimePlatform().database;\n if (!database) {\n throw new Error(\"SQL database binding not configured\");\n }\n return database;\n}\n\nexport function getPublicCache() {\n const cache = getDefaultCloudflareCache();\n if (!cache) {\n throw new Error(\"Cloudflare cache binding not configured\");\n }\n return createCloudflarePublicCacheAdapter(cache);\n}\n","import {\n getPublicCache as getCloudflarePublicCache,\n getRuntimePlatform as getCloudflareRuntimePlatform,\n} from \"./cloudflare-runtime\";\nimport { currentRuntimeId } from \"./selection\";\n\nexport function getRuntimePlatform() {\n return getCloudflareRuntimePlatform();\n}\n\nexport function getDatabase() {\n const platform = getRuntimePlatform();\n const database = platform.database;\n if (!database) {\n throw new Error(`SQL database adapter not configured for ${platform.id}`);\n }\n return database;\n}\n\nexport function getPublicCache() {\n return getCloudflarePublicCache();\n}\n\nexport function getKeyValueCache() {\n return getRuntimePlatform().keyValueCache;\n}\n\nexport const runtimeSelection = {\n currentRuntimeId,\n};\n"],"mappings":"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","names":["cache","env","options","getRuntimePlatform"]}
|