feat(auth): 新增系统设置功能

- 实现管理员登录和验证码验证功能
- 添加管理员登录流程,支持通过 Telegram 验证
- 实现登录挑战和会话管理,确保安全性
- 更新前端状态管理,处理登录和登出逻辑
- 增加系统配置管理,支持 Telegram 安全设置
- 新增数据库表以存储登录挑战和会话信息
This commit is contained in:
Jeff Gu committed 2026-04-13 19:07:16 +08:00
1 parent 1c9bd91aed
commit bd76a89ba1
21 files changed
+1820 -78

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
import { Context } from "hono";
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config";
import {
createAdminLoginChallenge,
createAdminSession,
deleteAdminLoginChallenge,
getAdminSessionHeaderName,
invalidateAdminSession,
sendAdminLoginCodeNotification,
sendAdminLoginResultNotification,
verifyAdminLoginChallenge,
} from "./auth_shared";
const loginStartRequestSchema = z.object({
token: z.string().trim().min(1, "管理员令牌不能为空"),
});
const loginStartResponseSchema = z.object({
requiresVerification: z.boolean(),
challengeId: z.nullable(z.string()),
challengeExpiresAt: z.nullable(z.string()),
sessionToken: z.nullable(z.string()),
sessionExpiresAt: z.nullable(z.string()),
});
const loginVerifyRequestSchema = z.object({
challengeId: z.string().trim().min(1, "challengeId 不能为空"),
code: z.string().trim().regex(/^\d{6}$/, "验证码必须为 6 位数字"),
});
const buildDirectLoginResponse = async (
c: Context<HonoCustomType>
) => {
const session = await createAdminSession(c);
return {
success: true,
data: {
requiresVerification: false,
challengeId: null,
challengeExpiresAt: null,
sessionToken: session.sessionToken,
sessionExpiresAt: session.expiresAt,
},
message: "Login successful",
} as CommonResponse;
};
export class AdminLoginStartEndpoint extends OpenAPIRoute {
schema = {
tags: ["Admin API"],
summary: "Start admin login",
request: {
body: {
content: {
"application/json": {
schema: loginStartRequestSchema,
},
},
},
},
responses: {
...CommonSuccessfulResponse(loginStartResponseSchema),
...CommonErrorResponse,
},
};
async handle(c: Context<HonoCustomType>) {
const parsedBody = loginStartRequestSchema.safeParse(
await c.req.json<{ token: string }>()
);
if (!parsedBody.success) {
return c.text(parsedBody.error.issues[0]?.message || "管理员令牌不能为空", 400);
}
const body = parsedBody.data;
const systemConfig = await getSystemConfig(c);
const securityConfig = systemConfig.adminSecurity;
const securityEnabled = isTelegramSecurityEnabled(securityConfig);
if (!c.env.ADMIN_TOKEN || body.token !== c.env.ADMIN_TOKEN) {
if (securityEnabled) {
await sendAdminLoginResultNotification(
securityConfig,
c,
"failure",
"管理员令牌错误"
).catch((error) => {
console.error("Failed to send Telegram login failure notification:", error);
});
}
return c.text("管理员令牌无效", 401);
}
if (!securityEnabled) {
return buildDirectLoginResponse(c);
}
const challenge = await createAdminLoginChallenge(c);
try {
await sendAdminLoginCodeNotification(
securityConfig,
c,
challenge.code,
challenge.expiresAt
);
} catch (error) {
await deleteAdminLoginChallenge(c, challenge.challengeId);
return c.text(
`Telegram 发送验证码失败:${error instanceof Error ? error.message : "unknown error"}`,
500
);
}
return {
success: true,
data: {
requiresVerification: true,
challengeId: challenge.challengeId,
challengeExpiresAt: challenge.expiresAt,
sessionToken: null,
sessionExpiresAt: null,
},
message: "Verification code sent",
} as CommonResponse;
}
}
export class AdminLoginVerifyEndpoint extends OpenAPIRoute {
schema = {
tags: ["Admin API"],
summary: "Verify admin login code",
request: {
body: {
content: {
"application/json": {
schema: loginVerifyRequestSchema,
},
},
},
},
responses: {
...CommonSuccessfulResponse(loginStartResponseSchema),
...CommonErrorResponse,
},
};
async handle(c: Context<HonoCustomType>) {
const parsedBody = loginVerifyRequestSchema.safeParse(
await c.req.json<{ challengeId: string; code: string }>()
);
if (!parsedBody.success) {
return c.text(parsedBody.error.issues[0]?.message || "验证码格式无效", 400);
}
const body = parsedBody.data;
const systemConfig = await getSystemConfig(c);
const securityConfig = systemConfig.adminSecurity;
if (!isTelegramSecurityEnabled(securityConfig)) {
return c.text("当前未开启 Telegram 登录验证", 400);
}
const result = await verifyAdminLoginChallenge(
c,
body.challengeId,
body.code
);
if (!result.ok) {
await sendAdminLoginResultNotification(
securityConfig,
c,
"failure",
result.reason
).catch((error) => {
console.error("Failed to send Telegram login failure notification:", error);
});
return c.text(result.reason || "验证码验证失败", 401);
}
const session = await createAdminSession(c);
await sendAdminLoginResultNotification(
securityConfig,
c,
"success"
).catch((error) => {
console.error("Failed to send Telegram login success notification:", error);
});
return {
success: true,
data: {
requiresVerification: false,
challengeId: null,
challengeExpiresAt: null,
sessionToken: session.sessionToken,
sessionExpiresAt: session.expiresAt,
},
message: "Login verified successfully",
} as CommonResponse;
}
}
export class AdminLogoutEndpoint extends OpenAPIRoute {
schema = {
tags: ["Admin API"],
summary: "Logout admin session",
responses: {
...CommonSuccessfulResponse(z.boolean()),
...CommonErrorResponse,
},
};
async handle(c: Context<HonoCustomType>) {
const sessionToken = c.req.header(getAdminSessionHeaderName());
await invalidateAdminSession(c, sessionToken);
return {
success: true,
data: true,
message: "Logout successful",
} as CommonResponse;
}
}
+403
View File
@@ -0,0 +1,403 @@
import { Context } from "hono";
const ADMIN_SESSION_HEADER = "x-admin-session";
const ADMIN_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const ADMIN_LOGIN_CHALLENGE_TTL_MS = 5 * 60 * 1000;
const ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS = 5;
const DEFAULT_SYSTEM_TIMEZONE = "Asia/Shanghai";
type LoginRequestMetadata = {
clientIp: string;
country: string;
region: string;
city: string;
colo: string;
timezone: string;
};
const firstNonEmpty = (...values: Array<string | null | undefined>): string => {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return "";
};
const formatTimestampInTimezone = (date: Date, timezone: string): string => {
const formatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: timezone,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const parts = formatter.formatToParts(date);
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}:${values.second} (${timezone})`;
};
const getRequestMetadata = (c: Context<HonoCustomType>): LoginRequestMetadata => {
const requestCf = (c.req.raw.cf || {}) as Partial<IncomingRequestCfProperties<unknown>>;
return {
clientIp: firstNonEmpty(
c.req.header("cf-connecting-ip"),
c.req.header("x-real-ip"),
c.req.header("x-forwarded-for")?.split(",")[0]
),
country: firstNonEmpty(requestCf.country),
region: firstNonEmpty(requestCf.region, requestCf.regionCode),
city: firstNonEmpty(requestCf.city),
colo: firstNonEmpty(requestCf.colo),
timezone: firstNonEmpty(requestCf.timezone),
};
};
const getLocationText = (metadata: LoginRequestMetadata): string => {
const location = [metadata.country, metadata.region, metadata.city]
.filter(Boolean)
.join(" / ");
return location || "未知位置";
};
const createTelegramMessage = (
lines: Array<string>
): string => {
return lines.filter(Boolean).join("\n");
};
const generateNumericCode = (): string => {
return String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0");
};
const generateSessionToken = (): string => {
return `${crypto.randomUUID().replace(/-/g, "")}${crypto.randomUUID().replace(/-/g, "")}`;
};
const toSha256Hex = async (value: string): Promise<string> => {
const bytes = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
};
const cleanupExpiredArtifacts = async (c: Context<HonoCustomType>) => {
const now = new Date().toISOString();
await Promise.all([
c.env.DB.prepare(
`DELETE FROM admin_login_challenge WHERE expires_at <= ?`
).bind(now).run(),
c.env.DB.prepare(
`DELETE FROM admin_session WHERE expires_at <= ?`
).bind(now).run(),
]);
};
const sendTelegramMessage = async (
securityConfig: AdminSecurityConfig,
text: string
): Promise<void> => {
const response = await fetch(
`https://api.telegram.org/bot${securityConfig.telegramBotToken}/sendMessage`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
chat_id: securityConfig.telegramChatId,
text,
}),
}
);
const payload = await response.json()
.catch(() => ({})) as { ok?: boolean; description?: string };
if (!response.ok || payload.ok !== true) {
throw new Error(payload.description || `HTTP ${response.status}`);
}
};
const buildBaseMessageLines = (
metadata: LoginRequestMetadata,
occurredAt: Date
): Array<string> => {
const clientTimezone = metadata.timezone || DEFAULT_SYSTEM_TIMEZONE;
return [
`时间(客户端):${formatTimestampInTimezone(occurredAt, clientTimezone)}`,
`时间(系统):${formatTimestampInTimezone(occurredAt, DEFAULT_SYSTEM_TIMEZONE)}`,
`IP:${metadata.clientIp || "未知 IP"}`,
`位置:${getLocationText(metadata)}`,
`节点:${metadata.colo || "未知节点"}${metadata.timezone ? ` · ${metadata.timezone}` : ""}`,
];
};
export const getAdminSessionHeaderName = (): string => {
return ADMIN_SESSION_HEADER;
};
export const createAdminSession = async (
c: Context<HonoCustomType>
): Promise<{ sessionToken: string; expiresAt: string }> => {
await cleanupExpiredArtifacts(c);
const sessionToken = generateSessionToken();
const tokenHash = await toSha256Hex(sessionToken);
const expiresAt = new Date(Date.now() + ADMIN_SESSION_TTL_MS).toISOString();
await c.env.DB.prepare(
`INSERT INTO admin_session (token_hash, expires_at)
VALUES (?, ?)`
).bind(tokenHash, expiresAt).run();
return {
sessionToken,
expiresAt,
};
};
export const invalidateAdminSession = async (
c: Context<HonoCustomType>,
sessionToken: string | null | undefined
): Promise<void> => {
if (!sessionToken) {
return;
}
const tokenHash = await toSha256Hex(sessionToken);
await c.env.DB.prepare(
`DELETE FROM admin_session WHERE token_hash = ?`
).bind(tokenHash).run();
};
export const validateAdminSession = async (
c: Context<HonoCustomType>,
sessionToken: string | null | undefined
): Promise<boolean> => {
if (!sessionToken) {
return false;
}
await cleanupExpiredArtifacts(c);
const tokenHash = await toSha256Hex(sessionToken);
const session = await c.env.DB.prepare(
`SELECT token_hash, expires_at FROM admin_session WHERE token_hash = ?`
).bind(tokenHash).first<Pick<AdminSessionRow, "token_hash" | "expires_at">>();
if (!session?.token_hash) {
return false;
}
if (Date.parse(session.expires_at) <= Date.now()) {
await c.env.DB.prepare(
`DELETE FROM admin_session WHERE token_hash = ?`
).bind(tokenHash).run();
return false;
}
await c.env.DB.prepare(
`UPDATE admin_session
SET last_used_at = datetime('now'),
updated_at = datetime('now')
WHERE token_hash = ?`
).bind(tokenHash).run();
return true;
};
export const createAdminLoginChallenge = async (
c: Context<HonoCustomType>
): Promise<{ challengeId: string; code: string; expiresAt: string }> => {
await cleanupExpiredArtifacts(c);
const challengeId = crypto.randomUUID();
const code = generateNumericCode();
const codeHash = await toSha256Hex(code);
const expiresAt = new Date(Date.now() + ADMIN_LOGIN_CHALLENGE_TTL_MS).toISOString();
const metadata = getRequestMetadata(c);
await c.env.DB.prepare(
`INSERT INTO admin_login_challenge (
id,
code_hash,
expires_at,
attempts,
max_attempts,
request_ip,
request_country,
request_region,
request_city,
request_colo,
request_timezone
) VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`
).bind(
challengeId,
codeHash,
expiresAt,
ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS,
metadata.clientIp,
metadata.country,
metadata.region,
metadata.city,
metadata.colo,
metadata.timezone
).run();
return {
challengeId,
code,
expiresAt,
};
};
export const deleteAdminLoginChallenge = async (
c: Context<HonoCustomType>,
challengeId: string
): Promise<void> => {
await c.env.DB.prepare(
`DELETE FROM admin_login_challenge WHERE id = ?`
).bind(challengeId).run();
};
export const verifyAdminLoginChallenge = async (
c: Context<HonoCustomType>,
challengeId: string,
code: string
): Promise<{
ok: boolean;
reason?: string;
}> => {
await cleanupExpiredArtifacts(c);
const challenge = await c.env.DB.prepare(
`SELECT * FROM admin_login_challenge WHERE id = ?`
).bind(challengeId).first<AdminLoginChallengeRow>();
if (!challenge?.id) {
return {
ok: false,
reason: "验证码已失效,请重新获取",
};
}
if (Date.parse(challenge.expires_at) <= Date.now()) {
await deleteAdminLoginChallenge(c, challengeId);
return {
ok: false,
reason: "验证码已过期,请重新获取",
};
}
if ((challenge.attempts || 0) >= (challenge.max_attempts || ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS)) {
await deleteAdminLoginChallenge(c, challengeId);
return {
ok: false,
reason: "验证码尝试次数过多,请重新获取",
};
}
const codeHash = await toSha256Hex(code);
if (codeHash !== challenge.code_hash) {
const nextAttempts = (challenge.attempts || 0) + 1;
if (nextAttempts >= (challenge.max_attempts || ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS)) {
await deleteAdminLoginChallenge(c, challengeId);
return {
ok: false,
reason: "验证码错误,已达到最大尝试次数",
};
}
await c.env.DB.prepare(
`UPDATE admin_login_challenge
SET attempts = ?, updated_at = datetime('now')
WHERE id = ?`
).bind(nextAttempts, challengeId).run();
return {
ok: false,
reason: "验证码错误",
};
}
await deleteAdminLoginChallenge(c, challengeId);
return {
ok: true,
};
};
export const sendAdminLoginCodeNotification = async (
securityConfig: AdminSecurityConfig,
c: Context<HonoCustomType>,
code: string,
expiresAt: string
): Promise<void> => {
const metadata = getRequestMetadata(c);
const occurredAt = new Date();
const expiresDate = new Date(expiresAt);
const clientTimezone = metadata.timezone || DEFAULT_SYSTEM_TIMEZONE;
await sendTelegramMessage(
securityConfig,
createTelegramMessage([
"【One API 管理后台】",
`登录验证码:${code}`,
`过期时间:${formatTimestampInTimezone(expiresDate, clientTimezone)}`,
...buildBaseMessageLines(metadata, occurredAt),
])
);
};
export const sendAdminLoginResultNotification = async (
securityConfig: AdminSecurityConfig,
c: Context<HonoCustomType>,
status: "success" | "failure",
reason?: string
): Promise<void> => {
const metadata = getRequestMetadata(c);
const occurredAt = new Date();
await sendTelegramMessage(
securityConfig,
createTelegramMessage([
"【One API 管理后台】",
`登录${status === "success" ? "成功" : "失败"}`,
reason ? `原因:${reason}` : "",
...buildBaseMessageLines(metadata, occurredAt),
])
);
};
export const sendTelegramTestNotification = async (
securityConfig: AdminSecurityConfig,
c: Context<HonoCustomType>
): Promise<void> => {
const metadata = getRequestMetadata(c);
const occurredAt = new Date();
await sendTelegramMessage(
securityConfig,
createTelegramMessage([
"【One API 管理后台】",
"Telegram 绑定测试成功",
...buildBaseMessageLines(metadata, occurredAt),
])
);
};
+7 -13
View File
@@ -2,10 +2,9 @@ import { Context } from "hono";
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import { CONSTANTS } from "../constants";
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
import { BillingConfig, normalizeBillingConfig } from "../billing";
import { getJsonSetting, saveSetting } from "../utils";
import { getSystemConfig, saveSystemConfig } from "../system-config";
const billingConfigSchema = z.object({
displayDecimals: z.number().int().min(0).max(9),
@@ -22,14 +21,9 @@ export class BillingConfigGetEndpoint extends OpenAPIRoute {
};
async handle(c: Context<HonoCustomType>) {
const billingConfig = await getJsonSetting<BillingConfig>(
c,
CONSTANTS.BILLING_CONFIG_KEY
);
return {
success: true,
data: normalizeBillingConfig(billingConfig),
data: normalizeBillingConfig(await getSystemConfig(c)),
} as CommonResponse;
}
}
@@ -55,13 +49,13 @@ export class BillingConfigUpdateEndpoint extends OpenAPIRoute {
async handle(c: Context<HonoCustomType>) {
const body = await c.req.json<BillingConfig>();
const currentSystemConfig = await getSystemConfig(c);
const config = normalizeBillingConfig(body);
await saveSetting(
c,
CONSTANTS.BILLING_CONFIG_KEY,
JSON.stringify(config)
);
await saveSystemConfig(c, {
...currentSystemConfig,
displayDecimals: config.displayDecimals,
});
return {
success: true,
+56 -10
View File
@@ -14,6 +14,16 @@ import {
import {
BillingConfigGetEndpoint, BillingConfigUpdateEndpoint
} from "./billing_api"
import {
SystemConfigGetEndpoint,
SystemConfigUpdateEndpoint,
TelegramTestMessageEndpoint,
} from "./system_api"
import {
AdminLoginStartEndpoint,
AdminLoginVerifyEndpoint,
AdminLogoutEndpoint,
} from "./auth_api"
import {
AnalyticsOverviewEndpoint,
AnalyticsTrendEndpoint,
@@ -21,28 +31,61 @@ import {
AnalyticsEventsEndpoint,
UsageLogSearchEndpoint,
} from "./analytics_api"
import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config"
import { getAdminSessionHeaderName, validateAdminSession } from "./auth_shared"
const app = new Hono<HonoCustomType>()
export const api = fromHono(app)
// Authentication Middleware - using environment variable
app.use('/api/admin/*', async (c, next) => {
const token = c.req.header('x-admin-token');
const adminToken = c.env.ADMIN_TOKEN;
if (!token || !adminToken || token !== adminToken) {
return c.text("Unauthorized", 401);
}
await next();
});
const PUBLIC_AUTH_ROUTES = new Set([
"/api/admin/auth/login",
"/api/admin/auth/verify",
]);
app.use('/api/admin/*', async (c, next) => {
await db.ensureReady(c);
await next();
});
// Authentication Middleware - using environment variable or admin session
app.use('/api/admin/*', async (c, next) => {
if (PUBLIC_AUTH_ROUTES.has(c.req.path)) {
await next();
return;
}
const sessionToken = c.req.header(getAdminSessionHeaderName());
if (await validateAdminSession(c, sessionToken)) {
await next();
return;
}
const systemConfig = await getSystemConfig(c);
const securityEnabled = isTelegramSecurityEnabled(systemConfig.adminSecurity);
const token = c.req.header('x-admin-token');
const adminToken = c.env.ADMIN_TOKEN;
if (!securityEnabled && token && adminToken && token === adminToken) {
await next();
return;
}
return c.text(
securityEnabled
? "Telegram 登录验证已开启,请先完成验证码登录"
: "Unauthorized",
401
);
});
api.post("/api/admin/db_initialize", DBInitializeEndpoint)
// Authentication routes
api.post("/api/admin/auth/login", AdminLoginStartEndpoint)
api.post("/api/admin/auth/verify", AdminLoginVerifyEndpoint)
api.post("/api/admin/auth/logout", AdminLogoutEndpoint)
api.get("/api/admin/channel", ChannelGetEndpoint)
api.post("/api/admin/channel/:key", ChannelUpsertEndpoint)
api.delete("/api/admin/channel/:key", ChannelDeleteEndpoint)
@@ -59,6 +102,9 @@ api.get("/api/admin/pricing", PricingGetEndpoint)
api.post("/api/admin/pricing", PricingUpdateEndpoint)
api.get("/api/admin/billing/config", BillingConfigGetEndpoint)
api.post("/api/admin/billing/config", BillingConfigUpdateEndpoint)
api.get("/api/admin/system/config", SystemConfigGetEndpoint)
api.post("/api/admin/system/config", SystemConfigUpdateEndpoint)
api.post("/api/admin/system/telegram/test", TelegramTestMessageEndpoint)
// Analytics management routes
api.get("/api/admin/analytics/overview", AnalyticsOverviewEndpoint)
+155
View File
@@ -0,0 +1,155 @@
import { Context } from "hono";
import { OpenAPIRoute } from "chanfana";
import { z } from "zod";
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
import {
getSystemConfig,
isTelegramSecurityEnabled,
normalizeAdminSecurityConfig,
normalizeSystemConfig,
saveSystemConfig,
} from "../system-config";
import { sendTelegramTestNotification } from "./auth_shared";
const adminSecurityConfigSchema = z.object({
enabled: z.boolean(),
telegramBotToken: z.string(),
telegramChatId: z.string(),
});
const apiDocsConfigSchema = z.object({
enabled: z.boolean(),
});
const systemConfigSchema = z.object({
displayDecimals: z.number().int().min(0).max(9),
adminSecurity: adminSecurityConfigSchema,
apiDocs: apiDocsConfigSchema,
});
const telegramTestRequestSchema = z.object({
telegramBotToken: z.string().trim().min(1, "Bot Token 不能为空"),
telegramChatId: z.string().trim().min(1, "Chat ID 不能为空"),
});
const ensureSystemConfigValid = (config: SystemConfig) => {
if (
config.adminSecurity.enabled
&& !isTelegramSecurityEnabled(config.adminSecurity)
) {
throw new Error("开启 Telegram 验证前,请先填写 Bot Token 和 Chat ID");
}
};
export class SystemConfigGetEndpoint extends OpenAPIRoute {
schema = {
tags: ["Admin API"],
summary: "Get system configuration",
responses: {
...CommonSuccessfulResponse(systemConfigSchema),
...CommonErrorResponse,
},
};
async handle(c: Context<HonoCustomType>) {
return {
success: true,
data: await getSystemConfig(c),
} as CommonResponse;
}
}
export class SystemConfigUpdateEndpoint extends OpenAPIRoute {
schema = {
tags: ["Admin API"],
summary: "Update system configuration",
request: {
body: {
content: {
"application/json": {
schema: systemConfigSchema,
},
},
},
},
responses: {
...CommonSuccessfulResponse(systemConfigSchema),
...CommonErrorResponse,
},
};
async handle(c: Context<HonoCustomType>) {
const body = await c.req.json<SystemConfig>();
const config = normalizeSystemConfig(body);
try {
ensureSystemConfigValid(config);
} catch (error) {
return c.text(
error instanceof Error ? error.message : "系统设置无效",
400
);
}
return {
success: true,
data: await saveSystemConfig(c, config),
message: "System config updated successfully",
} as CommonResponse;
}
}
export class TelegramTestMessageEndpoint extends OpenAPIRoute {
schema = {
tags: ["Admin API"],
summary: "Send Telegram test notification",
request: {
body: {
content: {
"application/json": {
schema: telegramTestRequestSchema,
},
},
},
},
responses: {
...CommonSuccessfulResponse(z.boolean()),
...CommonErrorResponse,
},
};
async handle(c: Context<HonoCustomType>) {
const parsedBody = telegramTestRequestSchema.safeParse(
await c.req.json<Pick<AdminSecurityConfig, "telegramBotToken" | "telegramChatId">>()
);
if (!parsedBody.success) {
return c.text(parsedBody.error.issues[0]?.message || "Telegram 配置无效", 400);
}
const body = parsedBody.data;
const securityConfig = {
...normalizeAdminSecurityConfig(body),
enabled: true,
} satisfies AdminSecurityConfig;
if (!isTelegramSecurityEnabled(securityConfig)) {
return c.text("请先填写有效的 Bot Token 和 Chat ID", 400);
}
try {
await sendTelegramTestNotification(securityConfig, c);
} catch (error) {
return c.text(
`Telegram 测试消息发送失败:${error instanceof Error ? error.message : "unknown error"}`,
500
);
}
return {
success: true,
data: true,
message: "Telegram test message sent successfully",
} as CommonResponse;
}
}
+1
View File
@@ -4,4 +4,5 @@ export const CONSTANTS = {
DB_VERSION: "v0.0.6",
MODEL_PRICING_KEY: "MODEL_PRICING_CONFIG",
BILLING_CONFIG_KEY: "BILLING_CONFIG",
SYSTEM_CONFIG_KEY: "SYSTEM_CONFIG",
}
+26
View File
@@ -24,6 +24,32 @@ CREATE TABLE IF NOT EXISTS settings (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS admin_login_challenge (
id TEXT PRIMARY KEY,
code_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 5,
request_ip TEXT DEFAULT '',
request_country TEXT DEFAULT '',
request_region TEXT DEFAULT '',
request_city TEXT DEFAULT '',
request_colo TEXT DEFAULT '',
request_timezone TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS admin_session (
token_hash TEXT PRIMARY KEY,
expires_at TEXT NOT NULL,
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_admin_login_challenge_expires_at
ON admin_login_challenge (expires_at);
CREATE INDEX IF NOT EXISTS idx_admin_session_expires_at
ON admin_session (expires_at);
`
let dbReadyPromise: Promise<void> | null = null;
+16
View File
@@ -4,6 +4,8 @@ import { cors } from 'hono/cors';
import { api as providerApi } from './providers'
import { api as adminApi } from './admin'
import { fromHono } from 'chanfana';
import db from './db';
import { getSystemConfig } from './system-config';
const FRONTEND_ENTRY = '/'
const FRONTEND_STATIC_PATHS = new Set([
@@ -22,6 +24,11 @@ const FRONTEND_STATIC_PREFIXES = [
'/src/',
]
const LOCAL_DEV_HOSTNAMES = new Set(['0.0.0.0', '127.0.0.1', '::1', 'localhost'])
const API_DOC_ROUTE_PATHS = new Set([
'/api/docs',
'/api/redocs',
'/api/openapi.json',
])
type AppContext = Context<HonoCustomType>
function isApiRequest(pathname: string): boolean {
@@ -96,6 +103,15 @@ openapi.use('/*', cors());
app.use('*', async (c, next) => {
const requestUrl = new URL(c.req.url)
if (API_DOC_ROUTE_PATHS.has(requestUrl.pathname)) {
await db.ensureReady(c);
const systemConfig = await getSystemConfig(c);
if (!systemConfig.apiDocs.enabled) {
return c.notFound();
}
}
if (isApiRequest(requestUrl.pathname)) {
await next()
return
+121
View File
@@ -0,0 +1,121 @@
import { Context } from "hono";
import {
DEFAULT_BILLING_DISPLAY_DECIMALS,
normalizeBillingDisplayDecimals,
} from "./billing";
import { CONSTANTS } from "./constants";
import { getJsonSetting, saveSetting } from "./utils";
export const DEFAULT_ADMIN_SECURITY_CONFIG: AdminSecurityConfig = {
enabled: false,
telegramBotToken: "",
telegramChatId: "",
};
export const DEFAULT_API_DOCS_CONFIG: ApiDocsConfig = {
enabled: true,
};
export const DEFAULT_SYSTEM_CONFIG: SystemConfig = {
displayDecimals: DEFAULT_BILLING_DISPLAY_DECIMALS,
adminSecurity: DEFAULT_ADMIN_SECURITY_CONFIG,
apiDocs: DEFAULT_API_DOCS_CONFIG,
};
const normalizeBoolean = (value: unknown, fallback = false): boolean => {
return typeof value === "boolean" ? value : fallback;
};
const normalizeString = (value: unknown): string => {
return typeof value === "string" ? value.trim() : "";
};
export const normalizeAdminSecurityConfig = (
value: Partial<AdminSecurityConfig> | null | undefined
): AdminSecurityConfig => {
return {
enabled: normalizeBoolean(value?.enabled),
telegramBotToken: normalizeString(value?.telegramBotToken),
telegramChatId: normalizeString(value?.telegramChatId),
};
};
export const normalizeApiDocsConfig = (
value: Partial<ApiDocsConfig> | null | undefined
): ApiDocsConfig => {
return {
enabled: normalizeBoolean(value?.enabled, DEFAULT_API_DOCS_CONFIG.enabled),
};
};
export const normalizeSystemConfig = (
value: Partial<SystemConfig> | null | undefined
): SystemConfig => {
return {
displayDecimals: normalizeBillingDisplayDecimals(value?.displayDecimals),
adminSecurity: normalizeAdminSecurityConfig(value?.adminSecurity),
apiDocs: normalizeApiDocsConfig(value?.apiDocs),
};
};
export const isTelegramSecurityEnabled = (
value: AdminSecurityConfig | SystemConfig | null | undefined
): boolean => {
const security = value && "adminSecurity" in value
? value.adminSecurity
: value;
if (!security) {
return false;
}
return security.enabled
&& security.telegramBotToken.length > 0
&& security.telegramChatId.length > 0;
};
export const getSystemConfig = async (
c: Context<HonoCustomType>
): Promise<SystemConfig> => {
const systemConfig = await getJsonSetting<SystemConfig>(
c,
CONSTANTS.SYSTEM_CONFIG_KEY
);
if (systemConfig) {
return normalizeSystemConfig(systemConfig);
}
const legacyBillingConfig = await getJsonSetting<BillingConfig>(
c,
CONSTANTS.BILLING_CONFIG_KEY
);
return normalizeSystemConfig({
displayDecimals: legacyBillingConfig?.displayDecimals,
});
};
export const saveSystemConfig = async (
c: Context<HonoCustomType>,
value: Partial<SystemConfig> | null | undefined
): Promise<SystemConfig> => {
const config = normalizeSystemConfig(value);
await saveSetting(
c,
CONSTANTS.SYSTEM_CONFIG_KEY,
JSON.stringify(config)
);
await saveSetting(
c,
CONSTANTS.BILLING_CONFIG_KEY,
JSON.stringify({
displayDecimals: config.displayDecimals,
} satisfies BillingConfig)
);
return config;
};