Files
blogflare/src/lib/server/cloudflare.ts
T

240 lines
7.0 KiB
TypeScript

import type { AnalyticsData, TrafficRow, TrafficScope } from '$lib/analytics';
import { configuredHostname, publicCacheTag } from './cache';
// Optional dashboard secrets are not generated from wrangler.jsonc.
type AnalyticsEnv = Env &
Partial<
Record<
| 'CF_API_TOKEN'
| 'CF_ZONE_ID'
| 'CF_ACCOUNT_ID'
| 'CF_D1_ID'
| 'CF_R2_BUCKET'
| 'CF_ASSETS_ZONE_ID',
string
>
>;
async function graphql<T>(
token: string,
query: string,
variables: Record<string, unknown> = {}
): Promise<T> {
const response = await fetch('https://api.cloudflare.com/client/v4/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(15000)
});
const result = (await response.json()) as { data?: T; errors?: { message: string }[] };
if (!response.ok || result.errors?.length || !result.data) {
throw new Error(
result.errors?.map((error) => error.message).join('; ') ||
`Cloudflare HTTP ${response.status}`
);
}
return result.data;
}
export async function purgePublicCache(platform: App.Platform | undefined): Promise<string | null> {
const env = platform?.env as AnalyticsEnv | undefined;
let error: string | null = null;
try {
if (!env?.CF_API_TOKEN || !env.CF_ZONE_ID) throw new Error('缺少 CF_API_TOKEN 或 CF_ZONE_ID');
const hostname = configuredHostname(env.CF_DOMAIN);
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${encodeURIComponent(env.CF_ZONE_ID)}/purge_cache`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.CF_API_TOKEN}`
},
body: JSON.stringify({ tags: [publicCacheTag(hostname)] }),
signal: AbortSignal.timeout(10000)
}
);
const result = (await response.json()) as { success?: boolean; errors?: { message: string }[] };
if (!response.ok || !result.success) {
throw new Error(
`Cloudflare 清理失败(HTTP ${response.status}):${result.errors?.map((item) => item.message).join('; ') || '未返回成功确认'}`
);
}
} catch (cause) {
error = cause instanceof Error ? cause.message : '缓存清理失败';
console.error('Public cache purge failed:', error);
}
// Persist failures so an asynchronous API failure is visible in the admin UI.
try {
await env?.DB.prepare(
"INSERT INTO settings (key, value, updated_at) VALUES ('cache.purge_error', ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP"
)
.bind(error || '')
.run();
} catch (cause) {
console.error('Failed to persist cache purge status', cause);
}
return error;
}
async function fetchTraffic(
token: string,
zoneId: string,
label: string,
domain: string | undefined,
now: Date
): Promise<TrafficScope> {
const scope: TrafficScope = { label, hostname: '', days: 30, rows: [] };
try {
scope.hostname = configuredHostname(domain);
// Never fall back to zone-wide traffic: that would include unrelated external images.
for (const days of [30, 7, 1]) {
scope.days = days;
try {
const data = await graphql<{
viewer: { zones: { httpRequestsAdaptiveGroups: TrafficRow[] }[] };
}>(
token,
`
query Traffic($zone: string, $filter: filter) {
viewer {
zones(filter: { zoneTag: $zone }) {
httpRequestsAdaptiveGroups(limit: 10000, filter: $filter) {
count
sum {
edgeResponseBytes
}
dimensions {
date
cacheStatus
clientRequestPath
}
}
}
}
}
`,
{
zone: zoneId,
filter: {
datetime_geq: new Date(now.getTime() - days * 86400000).toISOString(),
datetime_lt: now.toISOString(),
clientRequestHTTPHost: scope.hostname,
requestSource: 'eyeball'
}
}
);
const rows = data.viewer.zones[0]?.httpRequestsAdaptiveGroups;
if (!Array.isArray(rows))
throw new Error('未返回该 Zone 的数据,请检查域名归属和 Token 权限');
if (rows.length >= 10000) throw new Error('分组数量达到查询上限,无法显示完整统计');
scope.rows = rows;
return scope;
} catch (cause) {
const message = cause instanceof Error ? cause.message : '流量查询失败';
if (
days !== 1 &&
/time range|time window|date range|older than|too old|retention|cannot request data before|query time|分组数量/i.test(
message
)
)
continue;
throw cause;
}
}
} catch (cause) {
scope.error = cause instanceof Error ? cause.message : '流量查询失败';
}
return scope;
}
export async function fetchAnalytics(
platform: App.Platform | undefined,
assetsDomain?: string
): Promise<AnalyticsData> {
const env = platform?.env as AnalyticsEnv | undefined;
const token = env?.CF_API_TOKEN;
const zoneId = env?.CF_ZONE_ID;
const accountId = env?.CF_ACCOUNT_ID;
const d1Id = env?.CF_D1_ID;
const r2Bucket = env?.CF_R2_BUCKET;
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000).toISOString().split('T')[0];
const result: AnalyticsData = {
scopes: [],
d1: [],
r2: [],
missingEnvVars: !accountId || !d1Id || !r2Bucket
};
if (!token || !zoneId) {
result.errorMsg = '统计不可用:请配置 CF_API_TOKEN 和 CF_ZONE_ID';
return result;
}
const trafficPromise = Promise.all([
fetchTraffic(token, zoneId, '博客', env?.CF_DOMAIN, now),
fetchTraffic(token, env?.CF_ASSETS_ZONE_ID || zoneId, 'R2 附件', assetsDomain, now)
]);
let accountQuery = '';
if (accountId) {
let d1Query = '';
if (d1Id) {
d1Query = `
d1AnalyticsAdaptiveGroups(
limit: 10000,
filter: { date_geq: "${thirtyDaysAgo}", databaseId: ${JSON.stringify(d1Id)} }
) {
sum {
readQueries
writeQueries
rowsRead
rowsWritten
}
}
`;
}
let r2Query = '';
if (r2Bucket) {
r2Query = `
r2StorageAdaptiveGroups(
limit: 10000,
filter: { datetime_geq: "${thirtyDaysAgo}T00:00:00Z", bucketName: ${JSON.stringify(r2Bucket)} }
) {
max {
payloadSize
}
}
`;
}
if (d1Query || r2Query) {
accountQuery = `
accounts(filter: { accountTag: ${JSON.stringify(accountId)} }) {
${d1Query}
${r2Query}
}
`;
}
}
try {
if (accountQuery) {
const data = await graphql<{
viewer: {
accounts: {
d1AnalyticsAdaptiveGroups?: AnalyticsData['d1'];
r2StorageAdaptiveGroups?: AnalyticsData['r2'];
}[];
};
}>(token, `query { viewer { ${accountQuery} } }`);
const account = data.viewer.accounts[0];
if (!account) throw new Error('未返回账户资源数据');
result.d1 = account.d1AnalyticsAdaptiveGroups || [];
result.r2 = account.r2StorageAdaptiveGroups || [];
}
} catch (cause) {
result.errorMsg = `账户资源统计不可用:${cause instanceof Error ? cause.message : '查询失败'}`;
}
result.scopes = await trafficPromise;
return result;
}