diff --git a/README.md b/README.md index 9614d68..b4c3401 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,12 @@ yarn build > For detailed steps on configuring GitHub and Google OIDC authentication, please refer to the [OIDC Authentication Guide](./docs/integrations/oidc-auth.md). +## 统计配置 (Homepage Stats Configuration) + +首页“注册用户数 / 访问量”所需 Cloudflare 变量说明,请参阅 [Cloudflare Web Analytics 集成配置](./docs/integrations/cloudflare-web-analytics.md)。 + +> For Cloudflare variables used by homepage stats, see the [Cloudflare Web Analytics integration guide](./docs/integrations/cloudflare-web-analytics.md). + ## 开发指南 (Development Guidelines) 有关详细的编码标准、架构规则和 Agent 特定说明,请参阅 [AGENTS.md](./AGENTS.md)。 diff --git a/docs/README.md b/docs/README.md index 339fcd7..5f0ea7b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,6 +46,7 @@ This directory follows a standard open-source documentation layout and mirrors t - Integrations - `integrations/databases.md` - `integrations/cloud.md` + - `integrations/cloudflare-web-analytics.md` - `integrations/ai-providers.md` - Advanced - `advanced/performance.md` diff --git a/docs/integrations/cloudflare-web-analytics.md b/docs/integrations/cloudflare-web-analytics.md new file mode 100644 index 0000000..a8d612a --- /dev/null +++ b/docs/integrations/cloudflare-web-analytics.md @@ -0,0 +1,80 @@ +# Cloudflare Web Analytics 集成配置 + +本页说明首页统计接口 `/api/marketing/home-stats` 依赖的 3 个 Cloudflare 环境变量如何获取,以及应配置到哪里。 + +## 需要的环境变量 + +```bash +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_WEB_ANALYTICS_SITE_TAG= +``` + +## 变量获取方式 + +### 1) `CLOUDFLARE_API_TOKEN` + +用途:服务端调用 Cloudflare GraphQL API 读取访问量。 + +获取路径: + +1. 打开 Cloudflare 控制台,右上角头像 -> **My Profile** +2. 进入 **API Tokens** +3. 点击 **Create Token** +4. 建议创建仅只读 token,至少包含:**Account Analytics:Read**(作用域限定到目标 Account) +5. 复制生成后的 token(只显示一次) + +### 2) `CLOUDFLARE_ACCOUNT_ID` + +用途:GraphQL 查询时定位账号。 + +获取方式(任选其一): + +- 在 Cloudflare 控制台 URL 中,账号路径段通常就是 account id。 +- 在账号总览页面(Overview)侧边栏/页面信息中复制 Account ID。 + +### 3) `CLOUDFLARE_WEB_ANALYTICS_SITE_TAG` + +用途:定位具体 Web Analytics 站点。 + +获取方式(任选其一): + +- 你当前这类链接中可直接看到: + `.../web-analytics/overview?siteTag~in=&excludeBots=Yes` + 其中 `` 就是变量值。 +- 在 Cloudflare Web Analytics 的站点设置/安装脚本中,`siteTag`(或 beacon token)即对应值。 + +## 配置写入位置 + +### 本地开发 + +写入 `console.svc.plus/.env.local`: + +```bash +CLOUDFLARE_API_TOKEN=... +CLOUDFLARE_ACCOUNT_ID=... +CLOUDFLARE_WEB_ANALYTICS_SITE_TAG=... +``` + +### 线上部署 + +把同名变量写入 `console.svc.plus` 的部署环境(例如 Vercel/Cloud Run 的环境变量配置)。 + +> 注意:这些变量属于服务端密钥,不要暴露到 `NEXT_PUBLIC_*`。 + +## 联调验证 + +部署后访问: + +```bash +curl -fsSL https://console.svc.plus/api/marketing/home-stats +``` + +期望返回中 `visits.daily/weekly/monthly` 为数字(非 `null`)。 + +如果是 `null`,优先检查: + +1. token 权限是否包含 Analytics Read +2. Account ID 是否与 siteTag 属于同一账号 +3. 环境变量是否已在当前运行实例生效(重启/重新部署后再测) + diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 6c7e07f..da702bd 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -9,6 +9,8 @@ const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() type AccountUser = { id?: string uuid?: string + proxyUuid?: string + proxyUuidExpiresAt?: string name?: string username?: string email: string @@ -24,6 +26,7 @@ type AccountUser = { role?: string groups?: string[] permissions?: string[] + readOnly?: boolean tenantId?: string tenants?: Array<{ id?: string @@ -37,6 +40,23 @@ type SessionResponse = { error?: string } +function normalizeRole(role: unknown): string { + if (typeof role !== 'string') { + return 'user' + } + const normalized = role.trim().toLowerCase() + if (!normalized) { + return 'user' + } + if (normalized === 'root' || normalized === 'super_admin') { + return 'admin' + } + if (normalized === 'readonly' || normalized === 'read_only') { + return 'user' + } + return normalized +} + async function fetchSession(token: string) { try { const response = await fetch(`${ACCOUNT_API_BASE}/session`, { @@ -86,10 +106,8 @@ export async function GET(request: NextRequest) { : false const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled - const normalizedRole = - typeof rawUser.role === 'string' && rawUser.role.trim().length > 0 - ? rawUser.role.trim().toLowerCase() - : 'user' + const normalizedRole = normalizeRole(rawUser.role) + const rawRole = typeof rawUser.role === 'string' ? rawUser.role.trim().toLowerCase() : '' const normalizedGroups = Array.isArray(rawUser.groups) ? rawUser.groups .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) @@ -100,6 +118,21 @@ export async function GET(request: NextRequest) { .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) .map((value) => value.trim()) : [] + const normalizedReadOnly = + Boolean(rawUser.readOnly) || + normalizedGroups.some((group) => group.toLowerCase() === 'readonly role') || + rawRole === 'readonly' || + rawRole === 'read_only' || + String(rawUser.email ?? '').trim().toLowerCase() === 'demo@svc.plus' + const normalizedProxyUuid = + typeof rawUser.proxyUuid === 'string' && rawUser.proxyUuid.trim().length > 0 + ? rawUser.proxyUuid.trim() + : undefined + const normalizedProxyUuidExpiresAt = + typeof rawUser.proxyUuidExpiresAt === 'string' && rawUser.proxyUuidExpiresAt.trim().length > 0 + ? rawUser.proxyUuidExpiresAt.trim() + : undefined + const normalizedTenantId = typeof rawUser.tenantId === 'string' && rawUser.tenantId.trim().length > 0 ? rawUser.tenantId.trim() @@ -158,6 +191,9 @@ export async function GET(request: NextRequest) { role: normalizedRole, groups: normalizedGroups, permissions: normalizedPermissions, + readOnly: normalizedReadOnly, + proxyUuid: normalizedProxyUuid, + proxyUuidExpiresAt: normalizedProxyUuidExpiresAt, tenantId: normalizedTenantId, tenants: normalizedTenants, }, diff --git a/src/lib/userStore.ts b/src/lib/userStore.ts index ad9e890..2af0772 100644 --- a/src/lib/userStore.ts +++ b/src/lib/userStore.ts @@ -13,6 +13,8 @@ export type TenantMembership = { type User = { id: string uuid: string + proxyUuid?: string + proxyUuidExpiresAt?: string email: string name?: string username: string @@ -25,6 +27,7 @@ type User = { isUser: boolean isOperator: boolean isAdmin: boolean + isReadOnly: boolean tenantId?: string tenants?: TenantMembership[] mfa?: { @@ -50,6 +53,10 @@ type UserStore = { } const KNOWN_ROLE_MAP: Record = { + root: 'admin', + super_admin: 'admin', + readonly: 'user', + read_only: 'user', admin: 'admin', administrator: 'admin', operator: 'operator', @@ -97,6 +104,9 @@ async function fetchSessionUser(): Promise { role?: string groups?: string[] permissions?: string[] + proxyUuid?: string + proxyUuidExpiresAt?: string + readOnly?: boolean tenantId?: string tenants?: TenantMembership[] mfa?: { @@ -141,6 +151,7 @@ async function fetchSessionUser(): Promise { } const normalizedRole = normalizeRole(role) + const rawRole = typeof role === 'string' ? role.trim().toLowerCase() : '' const normalizedGroups = Array.isArray(groups) ? groups .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) @@ -151,6 +162,22 @@ async function fetchSessionUser(): Promise { .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) .map((value) => value.trim()) : [] + const normalizedEmail = typeof email === 'string' ? email.trim().toLowerCase() : '' + const inferredReadOnly = + rawRole === 'readonly' || + rawRole === 'read_only' || + normalizedEmail === 'demo@svc.plus' || + normalizedGroups.some((value) => value.toLowerCase() === 'readonly role') + const normalizedReadOnly = Boolean(sessionUser.readOnly ?? inferredReadOnly) + const normalizedProxyUuid = + typeof sessionUser.proxyUuid === 'string' && sessionUser.proxyUuid.trim().length > 0 + ? sessionUser.proxyUuid.trim() + : undefined + const normalizedProxyUuidExpiresAt = + typeof sessionUser.proxyUuidExpiresAt === 'string' && sessionUser.proxyUuidExpiresAt.trim().length > 0 + ? sessionUser.proxyUuidExpiresAt.trim() + : undefined + const normalizedTenantId = typeof sessionUser.tenantId === 'string' && sessionUser.tenantId.trim().length > 0 ? sessionUser.tenantId.trim() @@ -189,6 +216,8 @@ async function fetchSessionUser(): Promise { return { id: identifier, uuid: identifier, + proxyUuid: normalizedProxyUuid, + proxyUuidExpiresAt: normalizedProxyUuidExpiresAt, email, name: normalizedName, username: normalizedUsername ?? email, @@ -202,6 +231,7 @@ async function fetchSessionUser(): Promise { isUser: normalizedRole === 'user', isOperator: normalizedRole === 'operator', isAdmin: normalizedRole === 'admin', + isReadOnly: normalizedReadOnly, tenantId: normalizedTenantId, tenants: normalizedTenants, } diff --git a/src/modules/extensions/builtin/user-center/account/MfaSetupPanel.tsx b/src/modules/extensions/builtin/user-center/account/MfaSetupPanel.tsx index 8f4ae65..8bd981d 100644 --- a/src/modules/extensions/builtin/user-center/account/MfaSetupPanel.tsx +++ b/src/modules/extensions/builtin/user-center/account/MfaSetupPanel.tsx @@ -126,6 +126,7 @@ export default function MfaSetupPanel({ showSummary = true }: MfaSetupPanelProps const setupRequested = searchParams.get('setupMfa') === '1' const hasPendingMfa = Boolean(status?.totpPending && !status?.totpEnabled) const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending)) + const isReadOnlyAccount = Boolean(user?.isReadOnly) const resolveErrorMessage = useCallback( (code?: string | null) => { @@ -146,6 +147,7 @@ export default function MfaSetupPanel({ showSummary = true }: MfaSetupPanelProps 'mfa_challenge_creation_failed': copy.errors.provisioningFailed, 'mfa_status_failed': copy.errors.network, 'account_service_unreachable': copy.errors.network, + 'read_only_account': copy.errors.disableFailed, 'mfa_disable_failed': copy.errors.disableFailed, 'mfa_not_enabled': copy.errors.disableFailed, 'mfa_code_required': copy.errors.missingCode, @@ -450,6 +452,19 @@ export default function MfaSetupPanel({ showSummary = true }: MfaSetupPanelProps ) } + if (isReadOnlyAccount) { + return ( + +

{copy.title}

+

+ {language === 'zh' + ? 'Demo 体验账号已关闭 MFA,且账号为只读模式。你可以浏览控制台与使用二维码,但不能执行修改操作。' + : 'MFA is disabled for the Demo account and the account is read-only. You can browse and use the QR code, but changes are blocked.'} +

+
+ ) + } + const statusLabel = displayStatus?.totpEnabled ? copy.state.enabled : displayStatus?.totpPending || hasPendingMfa diff --git a/src/modules/extensions/builtin/user-center/components/UserOverview.tsx b/src/modules/extensions/builtin/user-center/components/UserOverview.tsx index 740f76b..14a3c53 100644 --- a/src/modules/extensions/builtin/user-center/components/UserOverview.tsx +++ b/src/modules/extensions/builtin/user-center/components/UserOverview.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { Copy } from 'lucide-react' @@ -45,15 +45,27 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview const mfaCopy = translations[language].userCenter.mfa const user = useUserStore((state) => state.user) const isLoading = useUserStore((state) => state.isLoading) + const refresh = useUserStore((state) => state.refresh) const logout = useUserStore((state) => state.logout) const [copied, setCopied] = useState(false) const displayName = useMemo(() => resolveDisplayName(user), [user]) - const uuid = user?.uuid ?? user?.id ?? '—' - const vlessUuid = user?.uuid ?? user?.id ?? null + const uuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? '—' + const vlessUuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? null const username = user?.username ?? '—' const email = user?.email ?? '—' const docsUrl = mfaCopy.actions.docsUrl + const isDemoReadOnly = Boolean(user?.isReadOnly && user?.email?.toLowerCase() === 'demo@svc.plus') + const demoUuidExpiresAtText = useMemo(() => { + if (!isDemoReadOnly || !user?.proxyUuidExpiresAt) { + return null + } + const date = new Date(user.proxyUuidExpiresAt) + if (Number.isNaN(date.getTime())) { + return null + } + return date.toLocaleString() + }, [isDemoReadOnly, user?.proxyUuidExpiresAt]) const mfaStatusLabel = useMemo(() => { if (user?.mfaEnabled) { @@ -68,7 +80,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending)) const handleCopy = useCallback(async () => { - const identifier = user?.uuid ?? user?.id + const identifier = user?.proxyUuid ?? user?.uuid ?? user?.id if (!identifier) { return } @@ -92,7 +104,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview } catch (error) { console.warn('Failed to copy UUID', error) } - }, [user?.id, user?.uuid]) + }, [user?.id, user?.proxyUuid, user?.uuid]) const handleGoToSetup = useCallback(() => { router.push('/panel/account?setupMfa=1') @@ -104,10 +116,34 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview router.refresh() }, [logout, router]) + useEffect(() => { + if (!isDemoReadOnly || !user?.proxyUuidExpiresAt) { + return + } + const expiresAt = new Date(user.proxyUuidExpiresAt).getTime() + if (!Number.isFinite(expiresAt)) { + return + } + const delay = Math.max(1000, expiresAt - Date.now() + 1500) + const timer = window.setTimeout(() => { + void refresh() + }, delay) + return () => { + window.clearTimeout(timer) + } + }, [isDemoReadOnly, refresh, user?.proxyUuidExpiresAt]) + return (

{copy.uuidNote}

+ {isDemoReadOnly ? ( +

+ {language === 'zh' + ? `Demo 体验账号为只读模式:可浏览控制台、可使用 VLESS 二维码,但不能修改任何配置。UUID 每 1 小时自动刷新${demoUuidExpiresAtText ? `(下次刷新约 ${demoUuidExpiresAtText})` : ''}。` + : `Demo account runs in read-only mode: browse safely and use the VLESS QR code, but no configuration changes are allowed. UUID rotates every hour${demoUuidExpiresAtText ? ` (next refresh around ${demoUuidExpiresAtText})` : ''}.`} +

+ ) : null}
{!hideMfaMainPrompt && requiresSetup ? ( diff --git a/src/server/account/session.ts b/src/server/account/session.ts index 5a02aac..dfa2c5e 100644 --- a/src/server/account/session.ts +++ b/src/server/account/session.ts @@ -58,6 +58,10 @@ type AccountSessionResponse = { } const KNOWN_ROLE_MAP: Record = { + root: 'admin', + super_admin: 'admin', + readonly: 'user', + read_only: 'user', admin: 'admin', administrator: 'admin', operator: 'operator', @@ -246,4 +250,3 @@ export async function getAccountSession(request?: NextRequest): Promise