架构迁移:前后端分离
This commit is contained in:
1 parent
5cc60c55d5
commit
86780f2ce0
25 files changed
+329
-106
No files matched your search
+5
-1
@@ -41,7 +41,10 @@ CRON_SECRET=replace-with-a-random-secret
|
||||
|
||||
# Better Auth
|
||||
BETTER_AUTH_SECRET=replace-with-a-random-secret
|
||||
BETTER_AUTH_URL=https://daoyou.example.com
|
||||
BETTER_AUTH_URL=https://api.daoyou.example.com
|
||||
PUBLIC_WEB_ORIGINS=https://app.daoyou.example.com
|
||||
# Optional: enable cross-subdomain cookies for app/api subdomains on the same site.
|
||||
BETTER_AUTH_COOKIE_DOMAIN=.daoyou.example.com
|
||||
BETTER_AUTH_DB_SCHEMA=better_auth
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
@@ -68,4 +71,5 @@ DISABLE_LLM_NAMING=
|
||||
# Frontend public build variables
|
||||
# Configure these in GitHub Actions Environment/Repository vars, not in the
|
||||
# server runtime env file passed to `docker run --env-file`.
|
||||
# VITE_API_BASE_URL=https://api.daoyou.example.com
|
||||
# VITE_TURNSTILE_SITE_KEY=your-public-turnstile-site-key
|
||||
@@ -33,5 +33,3 @@ jobs:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
|
||||
build-args: |
|
||||
VITE_TURNSTILE_SITE_KEY=${{ vars.VITE_TURNSTILE_SITE_KEY }}
|
||||
+1
-6
@@ -3,18 +3,13 @@
|
||||
FROM oven/bun:1.3.13 AS builder
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_TURNSTILE_SITE_KEY
|
||||
|
||||
# 1) 先拷贝依赖描述文件以利用 Docker layer 缓存
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# 2) 拷贝源码并构建
|
||||
COPY . .
|
||||
RUN if [ -n "$VITE_TURNSTILE_SITE_KEY" ]; then \
|
||||
export VITE_TURNSTILE_SITE_KEY; \
|
||||
fi; \
|
||||
bun run build
|
||||
RUN bun run build:server
|
||||
|
||||
FROM oven/bun:1.3.13 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
```text
|
||||
.
|
||||
├── src/index.ts # 根 Hono 应用,生产环境负责 SPA fallback
|
||||
├── src/index.ts # Bun 后端入口,导出 Hono API 与 WebSocket 配置
|
||||
├── src/server/ # Hono API、认证、服务层、数据库访问
|
||||
├── src/react-app/ # React SPA
|
||||
├── src/shared/ # 共享引擎、配置、类型、契约
|
||||
@@ -75,7 +75,7 @@
|
||||
- `src/react-app` 使用 `BrowserRouter` 管理前端路由
|
||||
- `src/server/app.ts` 提供 `/api/*` 和 `/internal/*` 接口
|
||||
- `src/index.ts` 在生产环境会注册 Bun 内置 cron,用于单容器内直接触发定时任务
|
||||
- `src/index.ts` 在生产环境对非 API 的 `GET/HEAD` 请求返回 `dist/index.html`,由前端 SPA 接管路由
|
||||
- 前端 SPA 独立部署到 Cloudflare Pages;后端 Docker 不再服务 `index.html` 或静态资源
|
||||
|
||||
当前路由约定:
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
- `/api/auth/*`:Better Auth
|
||||
- `/internal/cron/*`:内部定时任务接口
|
||||
- `/api/health-check`:健康检查
|
||||
- 其余如 `/login`、`/game`、`/admin`:前端 SPA 路由
|
||||
- 其余如 `/login`、`/game`、`/admin`:Cloudflare Pages 上的前端 SPA 路由
|
||||
|
||||
## 环境要求
|
||||
|
||||
@@ -114,7 +114,7 @@ cp .env.example .env.local
|
||||
| --- | --- |
|
||||
| `DATABASE_URL` | PostgreSQL 连接串 |
|
||||
| `BETTER_AUTH_SECRET` | Better Auth 密钥 |
|
||||
| `BETTER_AUTH_URL` | Better Auth 对外基准地址;本地开发通常填 `http://localhost:5173`,构建后本地运行通常填 `http://localhost:3000` |
|
||||
| `BETTER_AUTH_URL` | Better Auth 后端对外基准地址;生产填 API 域名,如 `https://api.example.com` |
|
||||
|
||||
### 建议同时配置
|
||||
|
||||
@@ -124,6 +124,8 @@ cp .env.example .env.local
|
||||
| `API_IP_RATE_LIMIT_WINDOW_SECONDS` | `/api/*` 全局 IP 令牌桶补充周期秒数;默认 `60` |
|
||||
| `API_IP_RATE_LIMIT_MAX_REQUESTS` | `/api/*` 同 IP 令牌桶容量和每周期补充 token 数;默认 `300` |
|
||||
| `BETTER_AUTH_DB_SCHEMA` | Better Auth schema 名;默认值为 `better_auth` |
|
||||
| `PUBLIC_WEB_ORIGINS` | 允许访问 API 的前端 origin,逗号分隔,如 `https://app.example.com,http://localhost:5173` |
|
||||
| `BETTER_AUTH_COOKIE_DOMAIN` | 可选;同站子域部署时可填 `.example.com` 启用跨子域 cookie |
|
||||
| `ADMIN_EMAILS` | 管理员邮箱白名单,逗号分隔 |
|
||||
|
||||
### 生产 cron 必需
|
||||
@@ -145,6 +147,7 @@ cp .env.example .env.local
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `VITE_API_BASE_URL` | 前端构建时注入的后端 API 基地址,如 `https://api.example.com` |
|
||||
| `VITE_TURNSTILE_SITE_KEY` | 前端构建时注入;没有它,登录/注册/找回密码页不会渲染验证码组件 |
|
||||
| `TURNSTILE_SECRET_KEY` 或 `TURNSTILE_SECRET` | 服务端校验 Turnstile 的密钥;未配置时服务端仍要求 token,但不会调用 Cloudflare 做真正校验 |
|
||||
|
||||
@@ -213,7 +216,9 @@ bun run dev
|
||||
| 命令 | 作用 |
|
||||
| --- | --- |
|
||||
| `bun run dev` | 本地开发 |
|
||||
| `bun run build` | 构建前端与服务端到 `dist/` |
|
||||
| `bun run build` | 依次构建前端与服务端 |
|
||||
| `bun run build:client` | 构建 Cloudflare Pages 使用的前端 SPA |
|
||||
| `bun run build:server` | 构建 Docker 使用的 Bun/Hono 后端 |
|
||||
| `bun run preview` | 先构建,再运行 `dist/index.js` |
|
||||
| `bun run start` | 直接运行已构建产物 |
|
||||
| `bun run lint` | ESLint 检查 |
|
||||
@@ -223,8 +228,8 @@ bun run dev
|
||||
|
||||
构建产物:
|
||||
|
||||
- `dist/index.html`:前端 SPA
|
||||
- `dist/index.js`:Bun 运行的 Hono 服务入口
|
||||
- `build:client` 产出前端 SPA
|
||||
- `build:server` 产出 Bun 运行的 Hono 服务入口 `dist/index.js`
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -233,9 +238,7 @@ bun run dev
|
||||
本地构建镜像:
|
||||
|
||||
```bash
|
||||
docker build -t daoyou-hono-bun:local \
|
||||
--build-arg VITE_TURNSTILE_SITE_KEY=your-public-site-key \
|
||||
-f Dockerfile .
|
||||
docker build -t daoyou-hono-bun:local -f Dockerfile .
|
||||
```
|
||||
|
||||
运行镜像:
|
||||
@@ -248,7 +251,7 @@ docker run --rm -p 3000:3000 \
|
||||
|
||||
注意:
|
||||
|
||||
- `VITE_TURNSTILE_SITE_KEY` 是前端构建期变量,不是单纯的运行时变量
|
||||
- `VITE_API_BASE_URL` 和 `VITE_TURNSTILE_SITE_KEY` 是前端 Pages 构建期变量,不进入后端 Docker 镜像
|
||||
- 服务运行时环境变量通过 shell、容器环境或 `--env-file` 注入
|
||||
|
||||
## 仓库内现成部署脚本
|
||||
@@ -337,7 +340,6 @@ docker compose up -d
|
||||
|
||||
- 构建 Docker 镜像
|
||||
- 推送到 Docker Hub
|
||||
- 将 `VITE_TURNSTILE_SITE_KEY` 作为构建参数注入
|
||||
|
||||
## 贡献指南
|
||||
|
||||
|
||||
+3
-2
@@ -8,8 +8,9 @@
|
||||
"auth:generate": "bunx auth@latest generate --config src/server/lib/auth/auth.ts --output src/server/lib/auth/better-auth.schema.sql --yes",
|
||||
"auth:info": "bunx auth@latest info --config src/server/lib/auth/auth.ts --json",
|
||||
"auth:migrate": "bunx auth@latest migrate --config src/server/lib/auth/auth.ts --yes",
|
||||
"build": "tsc -b && vite build --mode client && vite build",
|
||||
"build:client": "tsc -b && vite build --mode client",
|
||||
"build": "bun run build:client && bun run build:server",
|
||||
"build:client": "tsc -b tsconfig.app.json && vite build --mode client",
|
||||
"build:server": "tsc -b tsconfig.node.json && vite build --mode server",
|
||||
"check": "bun run build",
|
||||
"dev": "bun scripts/dev.ts",
|
||||
"dev:api": "bun scripts/dev-api.ts",
|
||||
|
||||
@@ -10,13 +10,8 @@ PORT="${PORT:-3000}"
|
||||
ENV_FILE="${ENV_FILE:-}"
|
||||
HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/api/health-check}"
|
||||
|
||||
BUILD_ARGS=()
|
||||
RUN_ARGS=()
|
||||
|
||||
if [ -n "${VITE_TURNSTILE_SITE_KEY:-}" ]; then
|
||||
BUILD_ARGS+=(--build-arg "VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY}")
|
||||
fi
|
||||
|
||||
if [ -n "${ENV_FILE}" ]; then
|
||||
if [ ! -f "${ENV_FILE}" ]; then
|
||||
echo "==> ENV_FILE not found: ${ENV_FILE}" >&2
|
||||
@@ -26,7 +21,7 @@ if [ -n "${ENV_FILE}" ]; then
|
||||
fi
|
||||
|
||||
echo "==> Building image: ${IMAGE_TAG}"
|
||||
docker build "${BUILD_ARGS[@]}" -t "${IMAGE_TAG}" -f Dockerfile .
|
||||
docker build -t "${IMAGE_TAG}" -f Dockerfile .
|
||||
|
||||
if docker ps -a --format '{{.Names}}' | awk -v name="${CONTAINER_NAME}" '$0 == name { found=1 } END { exit !found }'; then
|
||||
echo "==> Removing existing container: ${CONTAINER_NAME}"
|
||||
|
||||
+3
-68
@@ -1,73 +1,8 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { logger } from 'hono/logger';
|
||||
export { websocket } from 'hono/bun';
|
||||
import apiApp from './server/app';
|
||||
|
||||
import app from './server/app';
|
||||
import { registerInternalCronJobs } from './server/lib/jobs/internalCronScheduler';
|
||||
|
||||
const indexHtmlUrl = new URL('../dist/index.html', import.meta.url);
|
||||
const fileRequestPattern = /\/[^/?]+\.[^/]+$/;
|
||||
|
||||
let indexHtmlPromise: Promise<string> | undefined;
|
||||
|
||||
const getIndexHtml = () => {
|
||||
indexHtmlPromise ??= readFile(indexHtmlUrl, 'utf8');
|
||||
return indexHtmlPromise;
|
||||
};
|
||||
|
||||
const isStaticFileRequest = (path: string) =>
|
||||
path.startsWith('/assets/') || fileRequestPattern.test(path);
|
||||
|
||||
type RootAppOptions = {
|
||||
enableLogger?: boolean;
|
||||
isProd?: boolean;
|
||||
loadIndexHtml?: () => Promise<string>;
|
||||
};
|
||||
|
||||
export function createRootApp(options: RootAppOptions = {}) {
|
||||
const {
|
||||
enableLogger = true,
|
||||
isProd = import.meta.env.PROD,
|
||||
loadIndexHtml = getIndexHtml,
|
||||
} = options;
|
||||
const app = new Hono();
|
||||
|
||||
if (enableLogger) {
|
||||
app.use('/api/*', logger());
|
||||
app.use('/internal/*', logger());
|
||||
}
|
||||
|
||||
registerInternalCronJobs({ enabled: isProd });
|
||||
|
||||
app.route('/', apiApp);
|
||||
|
||||
// 生产环境中的文件请求,如果请求的是 API 或内部接口,则返回 404
|
||||
// 否则返回 index.html,并由前端路由处理
|
||||
app.notFound(async (c) => {
|
||||
if (c.req.path.startsWith('/api') || c.req.path.startsWith('/internal/')) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Not Found',
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const isPageRequest = c.req.method === 'GET' || c.req.method === 'HEAD';
|
||||
if (!isProd || !isPageRequest || isStaticFileRequest(c.req.path)) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
return c.html(await loadIndexHtml(), 200, {
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
const app = createRootApp();
|
||||
registerInternalCronJobs({ enabled: import.meta.env.PROD });
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
async function loadUrlModule(apiBaseUrl?: string) {
|
||||
vi.resetModules();
|
||||
vi.doMock('@app/lib/env', () => ({
|
||||
clientEnv: {
|
||||
apiBaseUrl,
|
||||
turnstileSiteKey: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
return import('./url');
|
||||
}
|
||||
|
||||
describe('api url helpers', () => {
|
||||
it('keeps relative api urls when no api base url is configured', async () => {
|
||||
const { resolveApiUrl } = await loadUrlModule();
|
||||
|
||||
expect(resolveApiUrl('/api/player/state')).toBe('/api/player/state');
|
||||
expect(resolveApiUrl('/assets/app.js')).toBe('/assets/app.js');
|
||||
});
|
||||
|
||||
it('resolves api urls against the configured api base url', async () => {
|
||||
const { resolveApiUrl } = await loadUrlModule('https://api.example.com');
|
||||
|
||||
expect(resolveApiUrl('/api/player/state')).toBe(
|
||||
'https://api.example.com/api/player/state',
|
||||
);
|
||||
});
|
||||
|
||||
it('derives websocket urls from the configured api base url', async () => {
|
||||
const { resolveRealtimeUrl } = await loadUrlModule('https://api.example.com');
|
||||
|
||||
expect(resolveRealtimeUrl()).toBe('wss://api.example.com/api/realtime');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { clientEnv } from '@app/lib/env';
|
||||
|
||||
export function resolveApiUrl(input: string) {
|
||||
if (!input.startsWith('/api/')) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (!clientEnv.apiBaseUrl) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return `${clientEnv.apiBaseUrl}${input}`;
|
||||
}
|
||||
|
||||
export function resolveRealtimeUrl() {
|
||||
if (clientEnv.apiBaseUrl) {
|
||||
const apiUrl = new URL(clientEnv.apiBaseUrl);
|
||||
apiUrl.protocol = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
apiUrl.pathname = '/api/realtime';
|
||||
apiUrl.search = '';
|
||||
apiUrl.hash = '';
|
||||
return apiUrl.toString();
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return '/api/realtime';
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}/api/realtime`;
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { i18nClient } from '@better-auth/i18n/client';
|
||||
import { emailOTPClient } from 'better-auth/client/plugins';
|
||||
import { createAuthClient } from 'better-auth/react';
|
||||
import { clientEnv } from '@app/lib/env';
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: clientEnv.apiBaseUrl,
|
||||
basePath: '/api/auth',
|
||||
fetchOptions: {
|
||||
credentials: 'include',
|
||||
},
|
||||
plugins: [i18nClient(), emailOTPClient()],
|
||||
});
|
||||
@@ -12,5 +12,6 @@ function getOptionalEnv(...names: string[]): string | undefined {
|
||||
}
|
||||
|
||||
export const clientEnv = {
|
||||
apiBaseUrl: getOptionalEnv('VITE_API_BASE_URL')?.replace(/\/+$/, ''),
|
||||
turnstileSiteKey: getOptionalEnv('VITE_TURNSTILE_SITE_KEY'),
|
||||
};
|
||||
@@ -2,6 +2,10 @@ import type { RealtimeServerEvent } from '@shared/contracts/realtime';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RealtimeClient } from './realtimeClient';
|
||||
|
||||
vi.mock('@app/lib/api/url', () => ({
|
||||
resolveRealtimeUrl: () => 'wss://api.example.com/api/realtime',
|
||||
}));
|
||||
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
@@ -81,7 +85,7 @@ describe('RealtimeClient', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(socket.url).toBe('ws://localhost:5173/api/realtime');
|
||||
expect(socket.url).toBe('wss://api.example.com/api/realtime');
|
||||
expect(socket.sent).toContain(JSON.stringify({ type: 'pong' }));
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'world-chat.message' }),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
RealtimeServerEvent,
|
||||
RealtimeServerEventType,
|
||||
} from '@shared/contracts/realtime';
|
||||
import { resolveRealtimeUrl } from '@app/lib/api/url';
|
||||
|
||||
type RealtimeListener<T extends RealtimeServerEventType> = (
|
||||
event: Extract<RealtimeServerEvent, { type: T }>,
|
||||
@@ -29,10 +30,7 @@ export class RealtimeClient {
|
||||
}
|
||||
|
||||
this.manuallyClosed = false;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(
|
||||
`${protocol}//${window.location.host}/api/realtime`,
|
||||
);
|
||||
const socket = new WebSocket(resolveRealtimeUrl());
|
||||
this.socket = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function authLayoutLoader({
|
||||
try {
|
||||
const response = await fetch('/api/community/announcement', {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
credentials: 'include',
|
||||
signal: request.signal,
|
||||
});
|
||||
const payload = (await response.json()) as AuthAnnouncementResponse;
|
||||
@@ -99,7 +99,7 @@ export async function requireAdminLoader({
|
||||
|
||||
const response = await fetch('/api/admin/session', {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
credentials: 'include',
|
||||
signal: request.signal,
|
||||
});
|
||||
const payload = (await response.json()) as AdminSessionResponse;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { resolveApiUrl } from './lib/api/url';
|
||||
import { router } from './router';
|
||||
import './index.css';
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
if (typeof input === 'string' && input.startsWith('/api/')) {
|
||||
init = { ...init, credentials: init?.credentials ?? 'include' };
|
||||
const raw = localStorage.getItem('daoyou_llm_config');
|
||||
if (raw) {
|
||||
try {
|
||||
@@ -22,6 +24,8 @@ window.fetch = async (input, init) => {
|
||||
// ignore invalid json
|
||||
}
|
||||
}
|
||||
|
||||
input = resolveApiUrl(input);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { handleAuthRequest } from '@server/lib/auth/hono';
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { apiCorsOptions } from '@server/lib/http/cors';
|
||||
import { runWithContext } from '@server/lib/http/context';
|
||||
import { apiIpRateLimit } from '@server/lib/hono/apiIpRateLimit';
|
||||
import { jsonError } from '@server/lib/hono/middleware';
|
||||
@@ -10,8 +13,16 @@ import internalRouter from '@server/routes/internal';
|
||||
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.use('/api/*', logger());
|
||||
app.use('/internal/*', logger());
|
||||
|
||||
app.use('*', async (context, next) => runWithContext(context, next));
|
||||
|
||||
app.use(
|
||||
'/api/*',
|
||||
cors(apiCorsOptions),
|
||||
);
|
||||
|
||||
app.use('*', async (context, next) => {
|
||||
const provider = context.req.header('x-llm-provider');
|
||||
const apiKey = context.req.header('x-llm-api-key');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { betterAuth } from 'better-auth';
|
||||
import { emailOTP } from 'better-auth/plugins/email-otp';
|
||||
import { sendViaSmtp } from '../admin/smtp';
|
||||
import { pgPool } from '../drizzle/db';
|
||||
import { getPublicWebOrigins } from '../http/origins';
|
||||
|
||||
function getRequiredEnv(name: 'BETTER_AUTH_SECRET' | 'BETTER_AUTH_URL') {
|
||||
const value = process.env[name];
|
||||
@@ -32,6 +33,19 @@ function getGitHubProviderConfig() {
|
||||
};
|
||||
}
|
||||
|
||||
function getCookieDomainConfig() {
|
||||
const domain = process.env.BETTER_AUTH_COOKIE_DOMAIN?.trim();
|
||||
|
||||
if (!domain) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
domain,
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackDisplayName(email: string, name?: string | null) {
|
||||
const trimmedName = name?.trim();
|
||||
|
||||
@@ -81,11 +95,15 @@ export const authSchemaName = getBetterAuthSchemaName();
|
||||
export const auth = betterAuth({
|
||||
baseURL: getRequiredEnv('BETTER_AUTH_URL'),
|
||||
secret: getRequiredEnv('BETTER_AUTH_SECRET'),
|
||||
trustedOrigins: getPublicWebOrigins(),
|
||||
database: pgPool,
|
||||
advanced: {
|
||||
database: {
|
||||
generateId: 'uuid',
|
||||
},
|
||||
...(getCookieDomainConfig()
|
||||
? { crossSubDomainCookies: getCookieDomainConfig() }
|
||||
: {}),
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { apiCorsOptions } from './cors';
|
||||
|
||||
const originalOrigins = process.env.PUBLIC_WEB_ORIGINS;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PUBLIC_WEB_ORIGINS = originalOrigins;
|
||||
});
|
||||
|
||||
function createCorsProbeApp() {
|
||||
const app = new Hono();
|
||||
app.use('/api/*', cors(apiCorsOptions));
|
||||
app.get('/api/probe', (c) => c.json({ success: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('api CORS options', () => {
|
||||
it('allows configured origins with credentials', async () => {
|
||||
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
|
||||
|
||||
const response = await createCorsProbeApp().request('/api/probe', {
|
||||
headers: { Origin: 'https://app.example.com' },
|
||||
});
|
||||
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe(
|
||||
'https://app.example.com',
|
||||
);
|
||||
expect(response.headers.get('access-control-allow-credentials')).toBe(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not echo unconfigured origins', async () => {
|
||||
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
|
||||
|
||||
const response = await createCorsProbeApp().request('/api/probe', {
|
||||
headers: { Origin: 'https://evil.example.com' },
|
||||
});
|
||||
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull();
|
||||
});
|
||||
|
||||
it('responds to preflight requests', async () => {
|
||||
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
|
||||
|
||||
const response = await createCorsProbeApp().request('/api/probe', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://app.example.com',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
'Access-Control-Request-Headers': 'x-turnstile-token,x-llm-provider',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe(
|
||||
'https://app.example.com',
|
||||
);
|
||||
expect(response.headers.get('access-control-allow-credentials')).toBe(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { resolveCorsOrigin } from './origins';
|
||||
|
||||
export const apiCorsOptions = {
|
||||
origin: resolveCorsOrigin,
|
||||
allowHeaders: [
|
||||
'Content-Type',
|
||||
'Authorization',
|
||||
'x-turnstile-token',
|
||||
'x-llm-provider',
|
||||
'x-llm-api-key',
|
||||
'x-llm-base-url',
|
||||
'x-llm-model',
|
||||
'x-llm-fast-model',
|
||||
],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
maxAge: 600,
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getPublicWebOrigins,
|
||||
isAllowedPublicWebOrigin,
|
||||
resolveCorsOrigin,
|
||||
} from './origins';
|
||||
|
||||
const originalOrigins = process.env.PUBLIC_WEB_ORIGINS;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PUBLIC_WEB_ORIGINS = originalOrigins;
|
||||
});
|
||||
|
||||
describe('public web origins', () => {
|
||||
it('parses comma-separated origins', () => {
|
||||
process.env.PUBLIC_WEB_ORIGINS =
|
||||
'https://app.example.com, http://localhost:5173 ';
|
||||
|
||||
expect(getPublicWebOrigins()).toEqual([
|
||||
'https://app.example.com',
|
||||
'http://localhost:5173',
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows missing origin for non-browser probes', () => {
|
||||
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
|
||||
|
||||
expect(isAllowedPublicWebOrigin(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves allowed CORS origins and rejects unknown origins', () => {
|
||||
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
|
||||
|
||||
expect(resolveCorsOrigin('https://app.example.com')).toBe(
|
||||
'https://app.example.com',
|
||||
);
|
||||
expect(resolveCorsOrigin('https://evil.example.com')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
function splitOrigins(value: string | undefined) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function getPublicWebOrigins() {
|
||||
return splitOrigins(process.env.PUBLIC_WEB_ORIGINS);
|
||||
}
|
||||
|
||||
export function isAllowedPublicWebOrigin(origin: string | undefined | null) {
|
||||
if (!origin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return getPublicWebOrigins().includes(origin);
|
||||
}
|
||||
|
||||
export function resolveCorsOrigin(origin: string) {
|
||||
return isAllowedPublicWebOrigin(origin) ? origin : '';
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { isAllowedPublicWebOrigin } from './origins';
|
||||
|
||||
export function isAllowedRealtimeOrigin(origin: string | undefined | null) {
|
||||
return isAllowedPublicWebOrigin(origin);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { isAllowedRealtimeOrigin } from '@server/lib/http/realtimeOrigin';
|
||||
|
||||
const originalOrigins = process.env.PUBLIC_WEB_ORIGINS;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PUBLIC_WEB_ORIGINS = originalOrigins;
|
||||
});
|
||||
|
||||
describe('realtime origin guard', () => {
|
||||
it('allows configured public web origins', () => {
|
||||
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
|
||||
|
||||
expect(isAllowedRealtimeOrigin('https://app.example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects browser websocket origins outside the allowlist', () => {
|
||||
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
|
||||
|
||||
expect(isAllowedRealtimeOrigin('https://evil.example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows missing origin for non-browser probes', () => {
|
||||
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
|
||||
|
||||
expect(isAllowedRealtimeOrigin(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { requireActiveCultivatorRef } from '@server/lib/hono/middleware';
|
||||
import type { AppEnv } from '@server/lib/hono/types';
|
||||
import { isAllowedRealtimeOrigin } from '@server/lib/http/realtimeOrigin';
|
||||
import { subscribePlayerStateEvents } from '@server/lib/services/playerStateBroadcaster';
|
||||
import { subscribeWorldChatMessages } from '@server/lib/services/worldChatBroadcaster';
|
||||
import type { RealtimeServerEvent } from '@shared/contracts/realtime';
|
||||
@@ -18,6 +19,10 @@ function sendEnvelope(ws: WSContext, event: RealtimeServerEvent) {
|
||||
}
|
||||
|
||||
router.get('/', requireActiveCultivatorRef(), async (c) => {
|
||||
if (!isAllowedRealtimeOrigin(c.req.header('origin'))) {
|
||||
return c.json({ success: false, error: 'Forbidden origin' }, 403);
|
||||
}
|
||||
|
||||
const ref = c.get('activeCultivatorRef');
|
||||
if (!ref) {
|
||||
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
|
||||
|
||||
+1
-2
@@ -42,8 +42,7 @@ export default defineConfig(({ command, mode }) => {
|
||||
plugins: [
|
||||
build({
|
||||
entry: './src/index.ts',
|
||||
staticRoot: './dist',
|
||||
emptyOutDir: false,
|
||||
emptyOutDir: true,
|
||||
entryContentDefaultExportHook: (
|
||||
appName,
|
||||
) => `export default websocket !== undefined
|
||||
|
||||
Reference in new issue
Block a user