@usethink/cf-core 0.3.0
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/README.md +88 -0
- package/dist/features/anti-abuse/index.d.ts +62 -0
- package/dist/features/anti-abuse/index.d.ts.map +1 -0
- package/dist/features/anti-abuse/index.js +139 -0
- package/dist/features/anti-abuse/index.js.map +1 -0
- package/dist/features/email/index.d.ts +68 -0
- package/dist/features/email/index.d.ts.map +1 -0
- package/dist/features/email/index.js +120 -0
- package/dist/features/email/index.js.map +1 -0
- package/dist/features/payment/fetch-utils.d.ts +17 -0
- package/dist/features/payment/fetch-utils.d.ts.map +1 -0
- package/dist/features/payment/fetch-utils.js +56 -0
- package/dist/features/payment/fetch-utils.js.map +1 -0
- package/dist/features/payment/index.d.ts +20 -0
- package/dist/features/payment/index.d.ts.map +1 -0
- package/dist/features/payment/index.js +21 -0
- package/dist/features/payment/index.js.map +1 -0
- package/dist/features/payment/providers/alipay.d.ts +30 -0
- package/dist/features/payment/providers/alipay.d.ts.map +1 -0
- package/dist/features/payment/providers/alipay.js +149 -0
- package/dist/features/payment/providers/alipay.js.map +1 -0
- package/dist/features/payment/providers/stripe.d.ts +34 -0
- package/dist/features/payment/providers/stripe.d.ts.map +1 -0
- package/dist/features/payment/providers/stripe.js +168 -0
- package/dist/features/payment/providers/stripe.js.map +1 -0
- package/dist/features/payment/providers/trc20.d.ts +24 -0
- package/dist/features/payment/providers/trc20.d.ts.map +1 -0
- package/dist/features/payment/providers/trc20.js +96 -0
- package/dist/features/payment/providers/trc20.js.map +1 -0
- package/dist/features/payment/registry.d.ts +43 -0
- package/dist/features/payment/registry.d.ts.map +1 -0
- package/dist/features/payment/registry.js +65 -0
- package/dist/features/payment/registry.js.map +1 -0
- package/dist/features/payment/types.d.ts +72 -0
- package/dist/features/payment/types.d.ts.map +1 -0
- package/dist/features/payment/types.js +8 -0
- package/dist/features/payment/types.js.map +1 -0
- package/dist/features/thompson-router/index.d.ts +101 -0
- package/dist/features/thompson-router/index.d.ts.map +1 -0
- package/dist/features/thompson-router/index.js +186 -0
- package/dist/features/thompson-router/index.js.map +1 -0
- package/dist/features/webhook/index.d.ts +76 -0
- package/dist/features/webhook/index.d.ts.map +1 -0
- package/dist/features/webhook/index.js +127 -0
- package/dist/features/webhook/index.js.map +1 -0
- package/dist/src/audit.d.ts +45 -0
- package/dist/src/audit.d.ts.map +1 -0
- package/dist/src/audit.js +40 -0
- package/dist/src/audit.js.map +1 -0
- package/dist/src/auth/jwt.d.ts +33 -0
- package/dist/src/auth/jwt.d.ts.map +1 -0
- package/dist/src/auth/jwt.js +87 -0
- package/dist/src/auth/jwt.js.map +1 -0
- package/dist/src/auth/password.d.ts +26 -0
- package/dist/src/auth/password.d.ts.map +1 -0
- package/dist/src/auth/password.js +52 -0
- package/dist/src/auth/password.js.map +1 -0
- package/dist/src/bootstrap.d.ts +74 -0
- package/dist/src/bootstrap.d.ts.map +1 -0
- package/dist/src/bootstrap.js +231 -0
- package/dist/src/bootstrap.js.map +1 -0
- package/dist/src/cache.d.ts +52 -0
- package/dist/src/cache.d.ts.map +1 -0
- package/dist/src/cache.js +76 -0
- package/dist/src/cache.js.map +1 -0
- package/dist/src/config.d.ts +83 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +96 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/crypto.d.ts +33 -0
- package/dist/src/crypto.d.ts.map +1 -0
- package/dist/src/crypto.js +87 -0
- package/dist/src/crypto.js.map +1 -0
- package/dist/src/db/connection.d.ts +53 -0
- package/dist/src/db/connection.d.ts.map +1 -0
- package/dist/src/db/connection.js +104 -0
- package/dist/src/db/connection.js.map +1 -0
- package/dist/src/db/index.d.ts +6 -0
- package/dist/src/db/index.d.ts.map +1 -0
- package/dist/src/db/index.js +6 -0
- package/dist/src/db/index.js.map +1 -0
- package/dist/src/db/schema.d.ts +649 -0
- package/dist/src/db/schema.d.ts.map +1 -0
- package/dist/src/db/schema.js +76 -0
- package/dist/src/db/schema.js.map +1 -0
- package/dist/src/error.d.ts +47 -0
- package/dist/src/error.d.ts.map +1 -0
- package/dist/src/error.js +94 -0
- package/dist/src/error.js.map +1 -0
- package/dist/src/http.d.ts +83 -0
- package/dist/src/http.d.ts.map +1 -0
- package/dist/src/http.js +116 -0
- package/dist/src/http.js.map +1 -0
- package/dist/src/idempotency.d.ts +78 -0
- package/dist/src/idempotency.d.ts.map +1 -0
- package/dist/src/idempotency.js +84 -0
- package/dist/src/idempotency.js.map +1 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +45 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/logger.d.ts +31 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +45 -0
- package/dist/src/logger.js.map +1 -0
- package/dist/src/middleware/admin-auth.d.ts +38 -0
- package/dist/src/middleware/admin-auth.d.ts.map +1 -0
- package/dist/src/middleware/admin-auth.js +55 -0
- package/dist/src/middleware/admin-auth.js.map +1 -0
- package/dist/src/middleware/api-key-auth.d.ts +42 -0
- package/dist/src/middleware/api-key-auth.d.ts.map +1 -0
- package/dist/src/middleware/api-key-auth.js +104 -0
- package/dist/src/middleware/api-key-auth.js.map +1 -0
- package/dist/src/middleware/index.d.ts +3 -0
- package/dist/src/middleware/index.d.ts.map +1 -0
- package/dist/src/middleware/index.js +3 -0
- package/dist/src/middleware/index.js.map +1 -0
- package/dist/src/rate-limit.d.ts +54 -0
- package/dist/src/rate-limit.d.ts.map +1 -0
- package/dist/src/rate-limit.js +134 -0
- package/dist/src/rate-limit.js.map +1 -0
- package/dist/src/security.d.ts +78 -0
- package/dist/src/security.d.ts.map +1 -0
- package/dist/src/security.js +175 -0
- package/dist/src/security.js.map +1 -0
- package/dist/src/types.d.ts +64 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +8 -0
- package/dist/src/types.js.map +1 -0
- package/features/anti-abuse/index.ts +180 -0
- package/features/anti-abuse/tests/index.test.ts +50 -0
- package/features/email/index.ts +172 -0
- package/features/email/tests/index.test.ts +44 -0
- package/features/payment/fetch-utils.ts +65 -0
- package/features/payment/index.ts +39 -0
- package/features/payment/providers/alipay.ts +171 -0
- package/features/payment/providers/stripe.ts +192 -0
- package/features/payment/providers/trc20.ts +115 -0
- package/features/payment/registry.ts +87 -0
- package/features/payment/tests/index.test.ts +506 -0
- package/features/payment/types.ts +93 -0
- package/features/telegram-miniapp/index.ts +109 -0
- package/features/telegram-miniapp/tests/index.test.ts +11 -0
- package/features/thompson-router/index.ts +243 -0
- package/features/thompson-router/tests/index.test.ts +93 -0
- package/features/webhook/index.ts +183 -0
- package/features/webhook/tests/index.test.ts +21 -0
- package/package.json +202 -0
- package/src/audit.ts +70 -0
- package/src/auth/jwt.ts +114 -0
- package/src/auth/password.ts +75 -0
- package/src/bootstrap.ts +322 -0
- package/src/cache.ts +78 -0
- package/src/config.ts +134 -0
- package/src/crypto.ts +106 -0
- package/src/db/connection.ts +127 -0
- package/src/db/index.ts +6 -0
- package/src/db/schema.ts +90 -0
- package/src/error.ts +125 -0
- package/src/http.ts +150 -0
- package/src/idempotency.ts +127 -0
- package/src/index.ts +85 -0
- package/src/logger.ts +63 -0
- package/src/middleware/admin-auth.ts +71 -0
- package/src/middleware/api-key-auth.ts +164 -0
- package/src/middleware/index.ts +2 -0
- package/src/rate-limit.ts +167 -0
- package/src/security.ts +219 -0
- package/src/types.ts +70 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @usethink/cf-core — Cloudflare Workers 共享内核
|
|
3
|
+
*
|
|
4
|
+
* 统一导出所有模块,支持两种导入方式:
|
|
5
|
+
*
|
|
6
|
+
* 1. 从根导入(适合小项目):
|
|
7
|
+
* import { ok, fail, sha256, verifyTurnstile } from "@usethink/cf-core";
|
|
8
|
+
*
|
|
9
|
+
* 2. 按子路径导入(推荐,tree-shakeable):
|
|
10
|
+
* import { ok, fail } from "@usethink/cf-core/http";
|
|
11
|
+
* import { sha256 } from "@usethink/cf-core/security";
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ── HTTP 工具 ──
|
|
15
|
+
export { ok, fail, failRateLimit, getOrigin, safeJsonBody, maskContact, normalizeCode, csvEscape, toCsv } from "./http";
|
|
16
|
+
|
|
17
|
+
// ── 安全工具 ──
|
|
18
|
+
export {
|
|
19
|
+
sha256,
|
|
20
|
+
constantTimeEqual,
|
|
21
|
+
getIpHash,
|
|
22
|
+
getClientIp,
|
|
23
|
+
getBearerToken,
|
|
24
|
+
verifyTurnstile,
|
|
25
|
+
buildSecurityHeaders,
|
|
26
|
+
type SecurityHeadersOptions,
|
|
27
|
+
} from "./security";
|
|
28
|
+
|
|
29
|
+
// ── 缓存 ──
|
|
30
|
+
export { createCache, cache } from "./cache";
|
|
31
|
+
|
|
32
|
+
// ── 限流 ──
|
|
33
|
+
export { MemoryRateLimiter, KvRateLimiter, DbRateLimiter, type RateLimiter } from "./rate-limit";
|
|
34
|
+
|
|
35
|
+
// ── 幂等性 ──
|
|
36
|
+
export { checkIdempotency, saveIdempotentResponse, getIdempotentResponse } from "./idempotency";
|
|
37
|
+
|
|
38
|
+
// ── 审计日志 ──
|
|
39
|
+
export { writeAdminAudit, type AuditInput } from "./audit";
|
|
40
|
+
|
|
41
|
+
// ── 系统配置 ──
|
|
42
|
+
export { SystemConfig, type SystemConfigOptions } from "./config";
|
|
43
|
+
|
|
44
|
+
// ── 错误处理 ──
|
|
45
|
+
export { classifyError, retryWithBackoff, ErrorType, type RetryOptions } from "./error";
|
|
46
|
+
|
|
47
|
+
// ── 结构化日志 ──
|
|
48
|
+
export { logger, type LogLevel, type LogEntry } from "./logger";
|
|
49
|
+
|
|
50
|
+
// ── 加解密 ──
|
|
51
|
+
export { encrypt, decrypt, isEncryptionAvailable, generateUUID } from "./crypto";
|
|
52
|
+
|
|
53
|
+
// ── 数据库 ──
|
|
54
|
+
export { initDatabase, initDatabaseWithHealthCheck, getOrCreateClient, createDrizzle, type DrizzleInstance } from "./db/connection";
|
|
55
|
+
|
|
56
|
+
// ── 公共 Schema ──
|
|
57
|
+
export {
|
|
58
|
+
systemConfig,
|
|
59
|
+
adminAuditLogs,
|
|
60
|
+
rateLimitWindows,
|
|
61
|
+
idempotencyKeys,
|
|
62
|
+
apiKeys,
|
|
63
|
+
} from "./db/schema";
|
|
64
|
+
|
|
65
|
+
// ── 认证 ──
|
|
66
|
+
export { signJwt, verifyJwt, extractJwt, type JwtPayload } from "./auth/jwt";
|
|
67
|
+
export { hashPassword, verifyPassword } from "./auth/password";
|
|
68
|
+
|
|
69
|
+
// ── 中间件 ──
|
|
70
|
+
export { createAdminAuth, type AdminAuthOptions } from "./middleware/admin-auth";
|
|
71
|
+
export { createApiKeyAuth, extractApiKey, type ApiKeyAuthOptions, type ApiKeyContext } from "./middleware/api-key-auth";
|
|
72
|
+
|
|
73
|
+
// ── Bootstrap ──
|
|
74
|
+
export { bootstrap, type BootstrapOptions } from "./bootstrap";
|
|
75
|
+
|
|
76
|
+
// ── 类型 ──
|
|
77
|
+
export type {
|
|
78
|
+
CoreBindings,
|
|
79
|
+
CoreVariables,
|
|
80
|
+
CoreEnv,
|
|
81
|
+
OkResponse,
|
|
82
|
+
FailResponse,
|
|
83
|
+
TurnstileResult,
|
|
84
|
+
RateLimitResult,
|
|
85
|
+
} from "./types";
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 结构化日志模块
|
|
3
|
+
*
|
|
4
|
+
* 提供 JSON 格式的结构化日志输出,便于 Workers 日志分析和 Cloudflare 日志查询。
|
|
5
|
+
*
|
|
6
|
+
* 来源:vcode src/lib/logger.ts
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
10
|
+
|
|
11
|
+
export interface LogEntry {
|
|
12
|
+
level: LogLevel;
|
|
13
|
+
message: string;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createEntry(level: LogLevel, message: string, meta?: Record<string, unknown>): LogEntry {
|
|
19
|
+
return { level, message, timestamp: new Date().toISOString(), ...meta };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const logger = {
|
|
23
|
+
debug(message: string, meta?: Record<string, unknown>) {
|
|
24
|
+
console.log(JSON.stringify(createEntry("debug", message, meta)));
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
info(message: string, meta?: Record<string, unknown>) {
|
|
28
|
+
console.log(JSON.stringify(createEntry("info", message, meta)));
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
warn(message: string, meta?: Record<string, unknown>) {
|
|
32
|
+
console.warn(JSON.stringify(createEntry("warn", message, meta)));
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
error(message: string, meta?: Record<string, unknown>) {
|
|
36
|
+
console.error(JSON.stringify(createEntry("error", message, meta)));
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/** 业务日志 — 资源操作(凭证/订单/卡密等) */
|
|
40
|
+
resourceAction(action: string, resourceId: string, meta?: Record<string, unknown>) {
|
|
41
|
+
this.info(`resource.${action}`, { resourceId, ...meta });
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/** 业务日志 — 渠道/驱动操作 */
|
|
45
|
+
channelAction(action: string, channel: string, meta?: Record<string, unknown>) {
|
|
46
|
+
this.info(`channel.${action}`, { channel, ...meta });
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/** 业务日志 — 定时任务 */
|
|
50
|
+
cronJob(jobName: string, meta?: Record<string, unknown>) {
|
|
51
|
+
this.info(`cron.${jobName}`, meta);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/** 安全日志 — 认证/授权事件 */
|
|
55
|
+
security(action: string, meta?: Record<string, unknown>) {
|
|
56
|
+
this.warn(`security.${action}`, meta);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/** 审计日志 — 管理操作 */
|
|
60
|
+
audit(action: string, adminId: string, targetId?: string, meta?: Record<string, unknown>) {
|
|
61
|
+
this.info(`audit.${action}`, { adminId, targetId, ...meta });
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 管理员认证中间件
|
|
3
|
+
*
|
|
4
|
+
* 支持两种认证方式:
|
|
5
|
+
* 1. Bearer Token(eshop/vcode 方式):Authorization: Bearer <token>
|
|
6
|
+
* 2. 自定义 Header(xtools 方式):X-Admin-Token: <token>
|
|
7
|
+
*
|
|
8
|
+
* 安全措施:
|
|
9
|
+
* - 时序安全比较(timingSafeEqual)防时序攻击
|
|
10
|
+
* - 默认 Token 仅限本地地址使用
|
|
11
|
+
* - 未配置 ADMIN_TOKEN 时返回 503
|
|
12
|
+
*
|
|
13
|
+
* 来源:eshop requireAdmin + xtools adminAuthMiddleware 合并
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Context, Next } from "hono";
|
|
17
|
+
import { fail } from "../http";
|
|
18
|
+
import { constantTimeEqual, getBearerToken } from "../security";
|
|
19
|
+
|
|
20
|
+
export interface AdminAuthOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Token 提取方式:
|
|
23
|
+
* - "bearer" — 从 Authorization: Bearer 提取(eshop/vcode 默认)
|
|
24
|
+
* - "header" — 从 X-Admin-Token 提取(xtools 默认)
|
|
25
|
+
* - "both" — 两种方式都尝试(兼容模式)
|
|
26
|
+
*/
|
|
27
|
+
mode?: "bearer" | "header" | "both";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 创建管理员认证中间件
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* // eshop/vcode 风格
|
|
35
|
+
* app.route("/admin", new Hono().use("*", createAdminAuth()).route("/", adminRoutes));
|
|
36
|
+
*
|
|
37
|
+
* // xtools 风格
|
|
38
|
+
* app.use("/api/admin/*", createAdminAuth({ mode: "header" }));
|
|
39
|
+
*/
|
|
40
|
+
export function createAdminAuth(options: AdminAuthOptions = {}) {
|
|
41
|
+
const { mode = "both" } = options;
|
|
42
|
+
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
|
+
return async (c: Context<any>, next: Next) => {
|
|
45
|
+
const expected = c.env.ADMIN_TOKEN;
|
|
46
|
+
if (!expected) return fail(c, "管理员令牌未配置", 503);
|
|
47
|
+
|
|
48
|
+
// 安全检查:默认 Token 仅限本地
|
|
49
|
+
const hostname = new URL(c.req.url).hostname;
|
|
50
|
+
if (
|
|
51
|
+
expected === "dev-only-change-me" &&
|
|
52
|
+
!["127.0.0.1", "localhost", "::1"].includes(hostname)
|
|
53
|
+
) {
|
|
54
|
+
return fail(c, "生产环境必须配置 ADMIN_TOKEN", 503);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 提取 Token
|
|
58
|
+
let actual = "";
|
|
59
|
+
if (mode === "bearer" || mode === "both") {
|
|
60
|
+
actual = getBearerToken(c);
|
|
61
|
+
}
|
|
62
|
+
if (!actual && (mode === "header" || mode === "both")) {
|
|
63
|
+
actual = c.req.header("X-Admin-Token") || "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!actual) return fail(c, "未授权", 401);
|
|
67
|
+
if (!constantTimeEqual(expected, actual)) return fail(c, "未授权", 401);
|
|
68
|
+
|
|
69
|
+
await next();
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Key 认证中间件
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* - SHA-256 哈希存储(不存明文)
|
|
6
|
+
* - 月度配额 + 原子递增(消除 TOCTOU 竞态)
|
|
7
|
+
* - 支持 Bearer 和自定义 Header 提取
|
|
8
|
+
* - 分级管理(free/basic/pro/enterprise)
|
|
9
|
+
*
|
|
10
|
+
* 来源:xtools + vcode api-auth.ts 合并
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Context, Next } from "hono";
|
|
14
|
+
import { fail } from "../http";
|
|
15
|
+
import { sha256 } from "../security";
|
|
16
|
+
import { apiKeys } from "../db/schema";
|
|
17
|
+
import { eq, and, sql } from "drizzle-orm";
|
|
18
|
+
|
|
19
|
+
export interface ApiKeyContext {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
tier: string;
|
|
23
|
+
userId: string;
|
|
24
|
+
monthlyQuota: number;
|
|
25
|
+
monthlyUsage: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ApiKeyDbLike {
|
|
29
|
+
select: (...args: unknown[]) => {
|
|
30
|
+
from: (table: typeof apiKeys) => {
|
|
31
|
+
where: (cond: unknown) => {
|
|
32
|
+
limit: (n: number) => Promise<Array<{
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
keyHash: string;
|
|
36
|
+
userId: string;
|
|
37
|
+
tier: string;
|
|
38
|
+
enabled: number;
|
|
39
|
+
monthlyQuota: number;
|
|
40
|
+
monthlyUsage: number;
|
|
41
|
+
monthlyResetAt: string | null;
|
|
42
|
+
expiresAt: string | null;
|
|
43
|
+
}>>;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
update: (table: typeof apiKeys) => {
|
|
48
|
+
set: (data: Record<string, unknown>) => {
|
|
49
|
+
where: (cond: unknown) => {
|
|
50
|
+
returning: () => Promise<unknown[]>;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ApiKeyAuthOptions {
|
|
57
|
+
/** Key 前缀(如 "xtools_"、"vcode_"),默认不限制 */
|
|
58
|
+
prefix?: string;
|
|
59
|
+
/** 是否必须认证,默认 true */
|
|
60
|
+
required?: boolean;
|
|
61
|
+
/** 自定义变量名(注入到 Hono Variables),默认 "apiKeyContext" */
|
|
62
|
+
variableName?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 从请求中提取 API Key
|
|
67
|
+
*/
|
|
68
|
+
export function extractApiKey(c: Context, prefix?: string): string | null {
|
|
69
|
+
// 1. Authorization: Bearer <key>
|
|
70
|
+
const auth = c.req.header("Authorization");
|
|
71
|
+
if (auth) {
|
|
72
|
+
const parts = auth.split(" ");
|
|
73
|
+
if (parts.length === 2) {
|
|
74
|
+
const [scheme, credentials] = parts;
|
|
75
|
+
if (
|
|
76
|
+
(scheme.toLowerCase() === "bearer" || scheme.toLowerCase() === "token") &&
|
|
77
|
+
(!prefix || credentials.startsWith(prefix))
|
|
78
|
+
) {
|
|
79
|
+
return credentials;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. X-API-Key header
|
|
85
|
+
const apiKey = c.req.header("X-API-Key");
|
|
86
|
+
if (apiKey && (!prefix || apiKey.startsWith(prefix))) {
|
|
87
|
+
return apiKey;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 创建 API Key 认证中间件
|
|
95
|
+
*/
|
|
96
|
+
export function createApiKeyAuth(options: ApiKeyAuthOptions = {}) {
|
|
97
|
+
const { prefix, required = true, variableName = "apiKeyContext" } = options;
|
|
98
|
+
|
|
99
|
+
return async (
|
|
100
|
+
c: Context<{ Bindings: Record<string, unknown>; Variables: Record<string, unknown> }>,
|
|
101
|
+
next: Next,
|
|
102
|
+
) => {
|
|
103
|
+
const key = extractApiKey(c, prefix);
|
|
104
|
+
|
|
105
|
+
if (!key) {
|
|
106
|
+
if (required) return fail(c, "缺少 API Key", 401);
|
|
107
|
+
await next();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const db = c.get("db") as ApiKeyDbLike | undefined;
|
|
112
|
+
if (!db) return fail(c, "数据库不可用", 503);
|
|
113
|
+
|
|
114
|
+
const keyHash = await sha256(key);
|
|
115
|
+
const rows = await db
|
|
116
|
+
.select()
|
|
117
|
+
.from(apiKeys)
|
|
118
|
+
.where(eq(apiKeys.keyHash, keyHash))
|
|
119
|
+
.limit(1);
|
|
120
|
+
|
|
121
|
+
if (rows.length === 0) return fail(c, "API Key 无效", 401);
|
|
122
|
+
|
|
123
|
+
const record = rows[0];
|
|
124
|
+
if (!record.enabled) return fail(c, "API Key 已禁用", 401);
|
|
125
|
+
if (record.expiresAt && new Date(record.expiresAt) < new Date()) {
|
|
126
|
+
return fail(c, "API Key 已过期", 401);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 原子配额检查 + 递增
|
|
130
|
+
if (record.monthlyQuota > 0) {
|
|
131
|
+
const updated = await db
|
|
132
|
+
.update(apiKeys)
|
|
133
|
+
.set({
|
|
134
|
+
monthlyUsage: sql`monthly_usage + 1`,
|
|
135
|
+
lastUsedAt: new Date().toISOString(),
|
|
136
|
+
})
|
|
137
|
+
.where(and(eq(apiKeys.id, record.id), sql`monthly_usage < monthly_quota`))
|
|
138
|
+
.returning();
|
|
139
|
+
|
|
140
|
+
if (updated.length === 0) return fail(c, "已超过月度配额", 429);
|
|
141
|
+
} else {
|
|
142
|
+
await db
|
|
143
|
+
.update(apiKeys)
|
|
144
|
+
.set({
|
|
145
|
+
monthlyUsage: sql`monthly_usage + 1`,
|
|
146
|
+
lastUsedAt: new Date().toISOString(),
|
|
147
|
+
})
|
|
148
|
+
.where(eq(apiKeys.id, record.id));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const context: ApiKeyContext = {
|
|
152
|
+
id: record.id,
|
|
153
|
+
name: record.name,
|
|
154
|
+
tier: record.tier || "free",
|
|
155
|
+
userId: record.userId,
|
|
156
|
+
monthlyQuota: record.monthlyQuota,
|
|
157
|
+
monthlyUsage: record.monthlyUsage,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
c.set(variableName, context);
|
|
161
|
+
c.set("userId", record.userId || record.id);
|
|
162
|
+
await next();
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一限流模块 — 支持 DB / KV / 内存 三种存储后端
|
|
3
|
+
*
|
|
4
|
+
* 三项目的限流实现各有特点:
|
|
5
|
+
* - eshop: DB 版(原子 upsert,最可靠,适合无 KV 的项目)
|
|
6
|
+
* - xtools: KV 版(滑动窗口,跨实例共享)
|
|
7
|
+
* - vcode: 内存版(最轻量,但重启丢失)
|
|
8
|
+
*
|
|
9
|
+
* 本模块统一接口,项目按自己的基础设施选择后端。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { sql } from "drizzle-orm";
|
|
13
|
+
import type { RateLimitResult } from "./types";
|
|
14
|
+
|
|
15
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
16
|
+
// 统一接口
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
18
|
+
|
|
19
|
+
export interface RateLimiter {
|
|
20
|
+
check(key: string, limit: number, windowMs: number): Promise<RateLimitResult>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
24
|
+
// 内存版(最轻量 — 适合 vcode 类项目)
|
|
25
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 内存固定窗口限流器
|
|
29
|
+
*
|
|
30
|
+
* Workers 实例级别,重启后重置。
|
|
31
|
+
* 适合请求量不大、对精确性要求不高的场景。
|
|
32
|
+
*/
|
|
33
|
+
export class MemoryRateLimiter implements RateLimiter {
|
|
34
|
+
private store = new Map<string, { count: number; resetAt: number }>();
|
|
35
|
+
|
|
36
|
+
async check(key: string, limit: number, windowMs: number): Promise<RateLimitResult> {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const record = this.store.get(key);
|
|
39
|
+
|
|
40
|
+
if (!record || now > record.resetAt) {
|
|
41
|
+
this.store.set(key, { count: 1, resetAt: now + windowMs });
|
|
42
|
+
return { ok: true, remaining: limit - 1 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (record.count >= limit) {
|
|
46
|
+
const resetMs = Math.max(0, record.resetAt - now);
|
|
47
|
+
return { ok: false, message: "请求过于频繁,请稍后再试", status: 429, resetMs };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
record.count++;
|
|
51
|
+
return { ok: true, remaining: limit - record.count };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
56
|
+
// KV 版(跨实例共享 — 适合 xtools 类项目)
|
|
57
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* KV 滑动窗口限流器
|
|
61
|
+
*
|
|
62
|
+
* 使用 Cloudflare KV 存储,跨 Workers 实例共享状态。
|
|
63
|
+
* 滑动窗口算法,避免固定窗口边界突发。
|
|
64
|
+
*
|
|
65
|
+
* 来源:xtools src/lib/rate-limiter.ts
|
|
66
|
+
*/
|
|
67
|
+
export class KvRateLimiter implements RateLimiter {
|
|
68
|
+
private kv: KVNamespace;
|
|
69
|
+
private prefix: string;
|
|
70
|
+
|
|
71
|
+
constructor(kv: KVNamespace, prefix = "rate") {
|
|
72
|
+
this.kv = kv;
|
|
73
|
+
this.prefix = prefix;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async check(key: string, limit: number, windowMs: number): Promise<RateLimitResult> {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const windowStart = now - windowMs;
|
|
79
|
+
const kvKey = `${this.prefix}:${key}`;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const data = await this.kv.get(kvKey, "json");
|
|
83
|
+
const timestamps: number[] = Array.isArray(data) ? data : [];
|
|
84
|
+
const valid = timestamps.filter((ts) => ts > windowStart);
|
|
85
|
+
|
|
86
|
+
if (valid.length >= limit) {
|
|
87
|
+
const oldest = Math.min(...valid);
|
|
88
|
+
const resetMs = Math.max(0, oldest + windowMs - now);
|
|
89
|
+
return { ok: false, message: "请求过于频繁,请稍后再试", status: 429, remaining: 0, resetMs };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
valid.push(now);
|
|
93
|
+
const ttlSeconds = Math.ceil(windowMs / 1000) + 10;
|
|
94
|
+
await this.kv.put(kvKey, JSON.stringify(valid), { expirationTtl: ttlSeconds });
|
|
95
|
+
|
|
96
|
+
const remaining = Math.max(0, limit - valid.length);
|
|
97
|
+
return { ok: true, remaining };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.warn("[KvRateLimiter] KV error, failing open:", err instanceof Error ? err.message : String(err));
|
|
100
|
+
return { ok: true, remaining: limit };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
106
|
+
// DB 版(最可靠 — 适合 eshop 类项目)
|
|
107
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 数据库固定窗口限流器
|
|
111
|
+
*
|
|
112
|
+
* 使用 rate_limit_windows 表,通过原子 upsert(INSERT ON CONFLICT UPDATE)实现。
|
|
113
|
+
* UPDATE 自带 WHERE request_count < :limit 条件,确保计数器永远不会超过阈值,
|
|
114
|
+
* 从根本上消除高并发场景下的竞态条件。
|
|
115
|
+
*
|
|
116
|
+
* 来源:eshop src/lib/rate-limit.ts(竞态修复版)
|
|
117
|
+
*/
|
|
118
|
+
export class DbRateLimiter implements RateLimiter {
|
|
119
|
+
private db: {
|
|
120
|
+
execute: (query: unknown) => Promise<{ rows?: Array<{ request_count: number }> }>;
|
|
121
|
+
};
|
|
122
|
+
private tableName: string;
|
|
123
|
+
|
|
124
|
+
constructor(db: DbRateLimiter["db"]) {
|
|
125
|
+
this.db = db;
|
|
126
|
+
this.tableName = "rate_limit_windows";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async check(key: string, limit: number, windowMs: number): Promise<RateLimitResult> {
|
|
130
|
+
const now = Math.floor(Date.now() / 1000);
|
|
131
|
+
const windowSeconds = Math.floor(windowMs / 1000);
|
|
132
|
+
const windowStart = Math.floor(now / windowSeconds) * windowSeconds;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
// 使用 raw SQL + WHERE 条件保证计数器不会超过 limit:
|
|
136
|
+
// - 首次插入: request_count = 1
|
|
137
|
+
// - 后续更新: request_count += 1,但 WHERE request_count < :limit 阻止超额
|
|
138
|
+
const result = await this.db.execute(
|
|
139
|
+
sql`INSERT INTO ${sql.identifier(this.tableName)} (action, ip_hash, window_start, request_count)
|
|
140
|
+
VALUES (${key}, '', ${windowStart}, 1)
|
|
141
|
+
ON CONFLICT(action, ip_hash, window_start) DO UPDATE SET
|
|
142
|
+
request_count = request_count + 1
|
|
143
|
+
WHERE rate_limit_windows.request_count < ${limit}
|
|
144
|
+
RETURNING request_count`,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const currentCount = result.rows?.[0]?.request_count ?? 0;
|
|
148
|
+
|
|
149
|
+
// 如果 WHERE 条件不满足(count >= limit),UPDATE 被跳过,返回旧值
|
|
150
|
+
if (currentCount > limit) {
|
|
151
|
+
const resetInSeconds = windowStart + windowSeconds - now;
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
message: "请求过于频繁,请稍后再试",
|
|
155
|
+
status: 429,
|
|
156
|
+
remaining: 0,
|
|
157
|
+
resetMs: Math.max(0, resetInSeconds * 1000),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { ok: true, remaining: Math.max(0, limit - currentCount) };
|
|
162
|
+
} catch {
|
|
163
|
+
// 限流失败时 fail-open 以保持服务可用
|
|
164
|
+
return { ok: true, remaining: limit };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|