diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ddbde52 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.codex +.agents +**/node_modules +.pnpm-store +**/dist +**/public +**/.env +**/.env.* +**/*.log +**/coverage +**/*.db +**/*.sqlite* diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..8f14492 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,27 @@ +# Copy to .env.production in the project root. Never commit real credentials. +# Replace the domain, database connection and initial admin password before startup. +NODE_ENV=production +DATABASE_URL="mysql://USER:URL_ENCODED_PASSWORD@MYSQL_HOST:3306/worthpath" +ADMIN_USERNAME=admin +ADMIN_PASSWORD=REPLACE_WITH_A_STRONG_INITIAL_PASSWORD + +API_HOST=0.0.0.0 +PORT=3100 +API_ALLOWED_HOSTS=worthpath.example.com +WEB_ORIGIN=https://worthpath.example.com + +MCP_PUBLIC_URL=https://worthpath.example.com/mcp +MCP_WEB_URL=https://worthpath.example.com +MCP_ALLOWED_HOSTS=worthpath.example.com +MCP_ALLOWED_ORIGINS=https://worthpath.example.com +MCP_ALLOW_LOOPBACK_HOSTS=false + +NETWORK_ALLOW_HTTP=false +NETWORK_ALLOW_HTTP_REDIRECTS=false +NETWORK_ALLOW_WILDCARD_ORIGINS=false +NETWORK_REQUIRE_SECURE_COOKIE=true +NETWORK_HSTS=true +NETWORK_UPGRADE_INSECURE_REQUESTS=true +NETWORK_RATE_LIMIT_ENABLED=true +COOKIE_SECURE=true +COOKIE_SAME_SITE=strict diff --git a/.gitignore b/.gitignore index 9385fe3..967dd8b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ coverage/ .env .env.* !.env.example +!.env.production.example +apps/api/public/ *.log *.db *.sqlite* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..10508ba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM node:24-bookworm-slim AS base + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* +RUN npm install -g pnpm@11.19.0 +WORKDIR /app + +FROM base AS build +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/web/package.json apps/web/package.json +COPY apps/api/prisma apps/api/prisma +RUN pnpm install --frozen-lockfile +COPY apps ./apps +RUN pnpm db:generate && pnpm build \ + && cp -r apps/web/dist apps/api/public + +FROM base AS runtime +ENV NODE_ENV=production +# Preserve pnpm's workspace links and Prisma CLI for explicit release migrations. +COPY --from=build --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/package.json /app/pnpm-workspace.yaml ./ +COPY --from=build --chown=node:node /app/apps/api/package.json ./apps/api/package.json +COPY --from=build --chown=node:node /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=build --chown=node:node /app/apps/api/dist ./apps/api/dist +COPY --from=build --chown=node:node /app/apps/api/public ./apps/api/public +COPY --from=build --chown=node:node /app/apps/api/prisma ./apps/api/prisma +COPY --from=build --chown=node:node /app/apps/api/assets ./apps/api/assets +COPY --from=build --chown=node:node /app/apps/api/scripts/database.cjs /app/apps/api/scripts/seed-icons.cjs ./apps/api/scripts/ +WORKDIR /app/apps/api +USER node +EXPOSE 3100 +CMD ["node", "dist/main.js"] diff --git a/README.md b/README.md index 14a63dd..107595f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # WorthPath +Docker 前后端合并容器部署见 [Docker 部署说明](docker.md)。 + 个人资产负债与净资产管理。NestJS + React/Vite + TypeScript + MySQL,支持手机和电脑。Node.js 22.12+、pnpm 11、MySQL 8+。 ```powershell diff --git a/apps/api/package.json b/apps/api/package.json index f783514..e1d30bf 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "dev": "node scripts/dev.cjs", "build": "tsc", "typecheck": "tsc --noEmit", - "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.test.ts ../web/test/net-worth-chart.test.ts ../web/test/login-preferences.test.ts", + "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts test/web.test.ts ../web/test/quick-entry.test.ts ../web/test/net-worth-chart.test.ts ../web/test/login-preferences.test.ts", "db:generate": "prisma generate", "db:migrate": "node scripts/database.cjs deploy", "db:status": "node scripts/database.cjs status", diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 96af907..54ca22c 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -26,6 +26,7 @@ import { AgentOperations } from './mcp/operations'; import { AgentFiles } from './mcp/files'; import { AgentTransport } from './mcp/transport'; import { AgentManagementController } from './mcp/management'; +import { installWeb } from './web'; @Catch() class SafeErrors implements ExceptionFilter { catch(error: unknown, host: ArgumentsHost) { @@ -132,6 +133,7 @@ async function bootstrap() { app.get(AgentTransport).install(app.getHttpAdapter().getInstance()); app.useGlobalFilters(new SafeErrors()); setupOpenApi(app); + installWeb(app.getHttpAdapter().getInstance()); app.enableShutdownHooks(); await app.listen(Number(process.env.PORT || 3100), network.apiHost); console.log('WorthPath API ready'); diff --git a/apps/api/src/web.ts b/apps/api/src/web.ts new file mode 100644 index 0000000..d1efe7e --- /dev/null +++ b/apps/api/src/web.ts @@ -0,0 +1,20 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { Express, static as serveStatic } from 'express'; + +/** Serve only the current Vite assets and page entries; leave service routes to Nest/OAuth. */ +export function installWeb(app: Express, directory = join(__dirname, '../public')) { + const index = join(directory, 'index.html'); + if (!existsSync(index)) return false; + + app.use('/assets', serveStatic(join(directory, 'assets'), { index: false, redirect: false })); + const pages = new Set(['/', '/agent/authorize', '/agent/operation']); + app.use((req, res, next) => { + if (!['GET', 'HEAD'].includes(req.method) || !pages.has(req.path)) return next(); + res.setHeader('Cache-Control', 'no-store'); + res.sendFile(index, (error) => { + if (error) next(error); + }); + }); + return true; +} diff --git a/apps/api/test/web.test.ts b/apps/api/test/web.test.ts new file mode 100644 index 0000000..d44726c --- /dev/null +++ b/apps/api/test/web.test.ts @@ -0,0 +1,80 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AddressInfo } from 'node:net'; +import { installWeb } from '../src/web'; + +test('combined web serves pages/assets without swallowing API, MCP, OAuth or missing assets', async () => { + const dir = await mkdtemp(join(tmpdir(), 'worthpath-web-')); + const app = express(); + let server: ReturnType | undefined; + try { + assert.equal(installWeb(app, join(dir, 'missing')), false); + await mkdir(join(dir, 'assets')); + await writeFile(join(dir, 'index.html'), 'WorthPath test page'); + await writeFile(join(dir, 'assets', 'app.js'), 'window.worthpath = true;'); + assert.equal(installWeb(app, dir), true); + // Register downstream service routes to detect accidental web interception. + for (const path of [ + '/api/probe', + '/mcp', + '/authorize', + '/token', + '/register', + '/revoke', + '/.well-known/oauth-authorization-server', + ]) { + app.all(path, (_req, res) => res.status(401).json({ service: path })); + } + app.use((_req, res) => res.status(404).json({ missing: true })); + server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server!.once('listening', resolve)); + const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + for (const path of [ + '/', + '/?agent_operation=test', + '/agent/authorize?agent_authorization=test', + '/agent/operation', + ]) { + const result = await fetch(base + path); + assert.equal(result.status, 200); + assert.match(result.headers.get('content-type') || '', /text\/html/); + assert.equal(result.headers.get('cache-control'), 'no-store'); + assert.match(await result.text(), /WorthPath test page/); + } + const head = await fetch(base + '/', { method: 'HEAD' }); + assert.equal(head.status, 200); + assert.equal(await head.text(), ''); + assert.match(await (await fetch(base + '/assets/app.js')).text(), /window.worthpath/); + for (const path of ['/assets/missing.js', '/api/unknown', '/unknown']) { + assert.equal((await fetch(base + path)).status, 404); + } + assert.equal((await fetch(base + '/', { method: 'POST' })).status, 404); + for (const path of [ + '/api/probe', + '/mcp', + '/authorize', + '/token', + '/register', + '/revoke', + '/.well-known/oauth-authorization-server', + ]) { + for (const method of ['GET', 'POST']) { + const result = await fetch(base + path, { method }); + assert.equal(result.status, 401); + assert.deepEqual(await result.json(), { service: path }); + } + } + } finally { + if (server) { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server!.close((error) => (error ? reject(error) : resolve())), + ); + } + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..ee2cfe5 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,18 @@ +services: + app: + image: worthpath:${WORTHPATH_IMAGE_TAG:-local} + build: + context: . + dockerfile: Dockerfile + env_file: + - .env.production + ports: + - '127.0.0.1:3100:3100' + restart: unless-stopped + init: true + stop_grace_period: 30s + logging: + driver: json-file + options: + max-size: '10m' + max-file: '3' diff --git a/docker.md b/docker.md new file mode 100644 index 0000000..e08a883 --- /dev/null +++ b/docker.md @@ -0,0 +1,228 @@ +# WorthPath Docker 部署 + +## 部署结构 + +前后端合并为一个应用容器:构建时编译 React/Vite 和 NestJS,将网页产物复制到 `apps/api/public`;运行时只启动 `node dist/main.js`。同一端口提供网页、`/api`、MCP 和 OAuth,不启动 Vite 开发服务。 + +```text +浏览器 / MCP 客户端 + ↓ HTTPS +服务器 Caddy / Nginx + ↓ http://127.0.0.1:3100 +WorthPath 应用容器 + ↓ +已有 MySQL 数据库 +``` + +本仓库的 `compose.yaml` 只管理应用,不创建数据库。需要已有 MySQL 8+、已创建的 `worthpath` 数据库及可用的数据库账号。Compose 使用命名镜像,可通过 `WORTHPATH_IMAGE_TAG` 区分版本。运行镜像保留 Prisma CLI 和相关依赖,以便单独执行迁移,不包含后端源码、测试或前端开发目录。 + +## Windows 本机安装 Docker 与构建镜像 + +本机开发/构建使用 Docker Desktop 的 WSL 2 后端,服务器运行使用 Docker Engine。你可以选择直接在 Linux 服务器构建,本机安装 Docker 不是服务器部署的前置条件。 + +先打开任务管理器 → 性能 → CPU,确认“虚拟化”已启用。如果未启用,需要在 BIOS/UEFI 打开硬件虚拟化。 + +以管理员身份打开 PowerShell,安装 WSL(不额外安装 Ubuntu): + +```powershell +wsl --install --no-distribution +``` + +按提示重启电脑,然后执行: + +```powershell +wsl --update +wsl --version +``` + +Docker Desktop 要求 WSL 2.1.5 或更新版本。参考 [Microsoft WSL 命令说明](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) 和 [Docker Desktop Windows 安装说明](https://docs.docker.com/desktop/setup/install/windows-install/)。 + +从上述 Docker 官方页面下载符合电脑架构的安装包(x64 电脑选择 x86_64),运行安装程序并选择 WSL 2 后端。安装完成后打开 Docker Desktop,完成其首次启动步骤,等待引擎启动,使用 Linux containers。 + +重新打开 PowerShell,验证客户端、引擎和 Compose: + +```powershell +docker version +docker compose version +docker info --format '{{.OSType}}' +docker run --rm hello-world +``` + +`docker version` 应同时显示 Client 和 Server,操作系统类型应为 `linux`,hello-world 应正常输出成功说明。只有客户端信息或“无法连接引擎”时,先检查 Docker Desktop 是否已启动。不要在 Windows containers 模式构建本项目。 + +然后在项目目录只构建镜像,不需要数据库配置,也不会执行数据库迁移: + +```powershell +cd E:\WorthPath +docker build -t worthpath:local . +``` + +如果 Docker 能运行但构建下载失败,检查错误对应的镜像仓库/npm/Debian 源网络和 Docker Desktop 代理配置,避免修改业务数据库配置来解决下载问题。 + +本机与服务器架构不同时,例如本机 x64、服务器 ARM64,需要按服务器架构构建镜像;上述默认命令按当前 Docker 引擎架构构建。服务器直接构建可以减少架构不匹配的问题。 + +## 当前验证记录 + +2026-10-05:已通过前后端 TypeScript 检查、前后端生产构建,以及网页托管/网络/Host 的 6 项回归测试。检查了真实环境文件的 Git 忽略规则。测试不连接业务数据库。 + +当前本机未找到 Docker 命令,WSL 状态检查提示需要安装;因此尚未完成 Linux 镜像构建、Compose 配置验证、容器内迁移或真实 HTTPS/MCP 联通验证。安装完成后先运行上面的环境验证和 `docker build`,再按后续步骤部署到服务器。 + +## 1. 准备服务器 + +建议使用 Linux 服务器,安装 Docker Engine 和 Docker Compose 插件,准备可用域名及 HTTPS 反向代理。以下部署命令在服务器的项目根目录执行,使用 Linux shell。 + +```bash +docker --version +docker compose version +``` + +服务器构建镜像需要访问镜像仓库、npm 包仓库和 Debian 软件源。镜像中固定 pnpm 11.19.0,并使用锁文件安装依赖;Node 基础镜像为 `node:24-bookworm-slim`,需要严格复现时另行固定镜像 digest。 + +## 2. 配置生产环境 + +首次部署复制模板;已有配置时不要再次覆盖: + +```bash +cp -n .env.production.example .env.production +chmod 600 .env.production +``` + +编辑根目录 `.env.production`,至少替换: + +| 配置 | 填写内容 | +| ---------------------------------------- | ----------------------------------- | +| `DATABASE_URL` | 服务器实际可访问的 MySQL 连接地址 | +| `ADMIN_PASSWORD` | 首次初始化管理员使用的强密码 | +| `API_ALLOWED_HOSTS`、`MCP_ALLOWED_HOSTS` | 真实域名;非默认端口需要包含端口 | +| `WEB_ORIGIN`、`MCP_ALLOWED_ORIGINS` | 网站完整 HTTPS 来源,通常无结尾斜杠 | +| `MCP_PUBLIC_URL` | `https://你的域名/mcp` | +| `MCP_WEB_URL` | `https://你的域名` | + +模板的生产安全开关已经关闭 HTTP 和通配来源,并开启 Secure Cookie。不要直接沿用 `apps/api/.env` 的开放开发配置。根目录 `.env.production` 由 Compose 注入容器,镜像不携带真实 `.env`;本地 `apps/api/.env` 不会被修改。 + +数据库密码中的特殊字符需 URL 编码。容器的 `localhost` 指容器自身,不能用它连接宿主机 MySQL: + +- 独立数据库服务器:填写实际可达地址,允许应用服务器访问。 +- 同一 Docker 网络的 MySQL:填写数据库服务名,例如 `db`,并将应用加入该网络。 +- Linux 宿主机 MySQL:可在 Compose 的 `app` 下加 `extra_hosts: ["host.docker.internal:host-gateway"]`,连接地址填 `host.docker.internal`。MySQL 必须监听容器可达的接口,并为容器来源配置账号授权与防火墙;仅监听 `127.0.0.1` 无法连接。 + +数据库不必暴露到公网。使用专用账号;迁移账号需要 DDL 权限,长期运行账号可在迁移后改为较小权限。应用不会自动创建数据库。 + +## 3. 配置 HTTPS + +默认发布端口为 `127.0.0.1:3100:3100`,供服务器上的反向代理访问,不对外直接开放 3100。 + +服务器上安装 Caddy 后,Caddyfile 可使用: + +```caddyfile +worthpath.example.com { + reverse_proxy 127.0.0.1:3100 +} +``` + +域名解析到服务器,允许访问 80/443。将示例域名替换成环境文件中的同一个域名,再按服务器已有方式验证并加载 Caddy 配置。 + +如果已有 Nginx,核心代理配置如下;证书和 HTTPS server 配置由你的服务器提供: + +```nginx +location / { + proxy_pass http://127.0.0.1:3100; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 300s; + client_max_body_size 512m; +} +``` + +所有路径都代理到应用,包括 `/api`、`/mcp`、OAuth 端点和 `/.well-known/*`。不要只代理 `/api`。代理需支持大文件上传(应用备份最大 512 MB),并保留请求 Host。 + +如果反向代理也在 Docker 中,`127.0.0.1` 是代理容器自身。需让两者加入同一 Docker 网络,将上游改为 `app:3100`,并确认仍保留外部 Host。此时可以移除宿主机端口发布。 + +当前应用的登录限流存储在进程内,且未配置受信反向代理 IP 解析。代理后的多个用户可能共用限流来源;增加副本不会共享限流计数。较多用户或多副本部署前,需要完善代理信任和共享限流策略。 + +## 4. 构建、迁移、启动 + +下面命令会修改所配置的数据库。已有数据先备份,并确认 `DATABASE_URL` 指向正确数据库。 + +```bash +# 编译前后端,生成 Linux Prisma 客户端;不连接数据库或执行迁移 +docker compose build app + +# 使用一次性容器执行 Prisma migrate deploy;失败后不要继续启动 +docker compose run --rm app node scripts/database.cjs deploy + +# 初始化/更新固定 ID 的内置图标;保留用户财务数据和上传图标 +docker compose run --rm app node scripts/seed-icons.cjs + +# 启动唯一的应用服务 +docker compose up -d app + +docker compose ps +docker compose logs --tail=100 app +``` + +上述一次性容器与应用使用相同镜像和环境文件。迁移或图标初始化失败时先解决错误;没有自动迁移、重置数据库或启动时自动改密。初始管理员只在数据库没有管理员时创建;首次网页登录后必须改密。部署到新数据库不会带上本机数据。 + +## 5. 验证 + +按顺序检查: + +1. 容器保持运行,日志显示 `WorthPath API ready`,没有循环重启。 +2. HTTPS 网站打开正常,浏览器网络面板中的 `/assets/*` JS/CSS 返回 200;刷新页面仍能打开。 +3. 登录、账户查询正常,登录 Cookie 带 Secure;无需启动 5173 端口的开发服务。 +4. `/agent/authorize` 和 `/agent/operation` 加上实际授权参数后,显示正确的授权或草稿确认页。 +5. 使用助手页面生成的新 HTTPS MCP 地址重新接入,验证 OAuth 发现、授权和只读查询。旧本机地址不会自动更新。 +6. `/api/不存在的路径` 与 `/assets/不存在的文件.js` 不应返回前端 HTML;未授权的服务请求应保持后端错误响应。 +7. 重启容器后仍可查询原数据;数据保存在 MySQL,应用容器可以替换。 + +首页连通性可通过域名检查,也可在服务器本机带正确 Host 检查: + +```bash +curl -I https://worthpath.example.com/ +curl -I -H 'Host: worthpath.example.com' http://127.0.0.1:3100/ +``` + +本机 HTTP 检查只能确认页面服务连通;生产登录和 Secure Cookie 需要通过 HTTPS 验证。 + +## 6. 更新与回退 + +建议为每次发布使用唯一版本标签,例如: + +```bash +export WORTHPATH_IMAGE_TAG=20261005-1 +docker compose build app +``` + +更新顺序:备份数据库 → 保存旧版本标签/镜像 → 更新源码 → 构建新镜像 → 执行迁移 → 按需运行图标初始化 → 替换应用 → 完成上述验证。 + +```bash +docker compose run --rm app node scripts/database.cjs deploy +docker compose up -d --no-deps app +docker compose logs --tail=100 app +``` + +回退应用时切换到保留的旧镜像标签,使用 `docker compose up -d --no-build app`。应用镜像回退不会回退数据库迁移;必须确认旧代码兼容新 schema,否则需按已验证的数据库恢复方案处理。 + +停止应用: + +```bash +docker compose stop app +``` + +数据库单独备份到服务器之外,并验证恢复。应用镜像、容器和数据卷均不替代数据库备份;数据库账号、登录授权等完整迁移应采用数据库级迁移方案。个人业务备份导出/恢复仍由用户在网站操作。 + +## 7. 本地开发与排查 + +原有 `pnpm dev` 保持前后端分开开发;没有 `apps/api/public/index.html` 时,API 不启用网页托管。本地验证合并网页可以先 `pnpm build`,再将 `apps/web/dist` 复制为 `apps/api/public`,此目录已加入 Git/Docker 忽略规则。清理该目录后恢复纯 API 开发入口。 + +- 页面 403:检查代理保留的 Host 与 `API_ALLOWED_HOSTS` 是否一致。 +- MCP Host/Origin 错误:检查固定 HTTPS 地址和白名单,而不是改成 `*`。 +- 登录 Cookie 未发送:检查使用 HTTPS、`COOKIE_SECURE`、网页来源及浏览器 Cookie 状态。 +- 数据库连接失败:检查容器可达地址、监听接口、账号来源授权与密码编码,不要打印真实连接字符串。 +- 缺少表:确认迁移成功,不要使用 reset 处理生产数据。 +- JS/CSS 404:检查镜像构建完成;如果新增 Vite 的其他顶层静态文件目录,需要同步扩展 `apps/api/src/web.ts` 的托管路径。 + +运行镜像包含 Prisma/迁移所需依赖,优先保证发布操作完整;后续可进一步拆分迁移镜像以减小应用镜像。日志设置了轮转,容器使用非 root 用户。镜像构建和服务器端完整验证必须在安装了 Docker 的环境执行。 diff --git a/scripts/check-staged.mjs b/scripts/check-staged.mjs index d460438..1a3a064 100644 --- a/scripts/check-staged.mjs +++ b/scripts/check-staged.mjs @@ -6,7 +6,8 @@ if ( names.some( (n) => /(^|\/)(\.env(?:\..*)?|node_modules|dist|coverage)(\/|$)/.test(n) && - !n.endsWith('.env.example'), + !n.endsWith('.env.example') && + n !== '.env.production.example', ) || names.some((n) => /\.(db|sqlite|log)$/.test(n)) )