diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..09b3a02 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.env +.manus +.manus-logs +node_modules +dist +test-results +playwright-report +coverage +tmp +temp diff --git a/.env.example b/.env.example index d4de39a..f51bd1d 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,7 @@ -NODE_ENV=production PORT=3000 # App auth / storage / database -DATABASE_URL=mysql://user:password@127.0.0.1:4000/tennis_training_hub +DATABASE_URL=mysql://tennis:replace-with-db-password@db:3306/tennis_training_hub JWT_SECRET=replace-with-strong-secret VITE_APP_ID=tennis-training-hub OAUTH_SERVER_URL= @@ -12,6 +11,13 @@ BUILT_IN_FORGE_API_KEY= VITE_OAUTH_PORTAL_URL= VITE_FRONTEND_FORGE_API_URL= VITE_FRONTEND_FORGE_API_KEY= +LOCAL_STORAGE_DIR=/data/app/storage + +# Compose MySQL +MYSQL_DATABASE=tennis_training_hub +MYSQL_USER=tennis +MYSQL_PASSWORD=replace-with-db-password +MYSQL_ROOT_PASSWORD=replace-with-root-password # LLM chat completion endpoint LLM_API_URL=https://one.hao.work/v1/chat/completions diff --git a/README.md b/README.md index a388009..5434ec1 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ pnpm exec playwright install chromium 单机部署推荐: 1. 宿主机 nginx 处理 `80/443` 和 TLS -2. `docker compose up -d --build` 启动 `app + media + worker` -3. nginx 将 `/` 转发到 `app:3000`,`/media/` 转发到 `media:8081` +2. `docker compose up -d --build` 启动 `app + media + worker + db` +3. nginx 将 `/` 转发到宿主机 `127.0.0.1:3002 -> app:3000`,`/media/` 转发到 `127.0.0.1:8081 -> media:8081` 详细步骤见: @@ -92,9 +92,14 @@ pnpm exec playwright install chromium - `DATABASE_URL` - `JWT_SECRET` +- `MYSQL_DATABASE` +- `MYSQL_USER` +- `MYSQL_PASSWORD` +- `MYSQL_ROOT_PASSWORD` - `LLM_API_URL` - `LLM_API_KEY` - `LLM_MODEL` +- `LOCAL_STORAGE_DIR` - `MEDIA_SERVICE_URL` - `VITE_MEDIA_BASE_URL` diff --git a/client/src/const.test.ts b/client/src/const.test.ts new file mode 100644 index 0000000..361d27d --- /dev/null +++ b/client/src/const.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getLoginUrl } from "./const"; + +describe("getLoginUrl", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("falls back to the in-app login route when oauth portal is unset", () => { + vi.stubEnv("VITE_OAUTH_PORTAL_URL", ""); + + expect(getLoginUrl()).toBe("/login"); + }); + + it("builds the external oauth login url when portal is configured", () => { + vi.stubEnv("VITE_OAUTH_PORTAL_URL", "https://oauth.example.com"); + vi.stubEnv("VITE_APP_ID", "tennis-training-hub"); + + const url = new URL(getLoginUrl()); + + expect(url.origin).toBe("https://oauth.example.com"); + expect(url.pathname).toBe("/app-auth"); + expect(url.searchParams.get("appId")).toBe("tennis-training-hub"); + expect(url.searchParams.get("redirectUri")).toBe( + `${window.location.origin}/api/oauth/callback` + ); + expect(url.searchParams.get("type")).toBe("signIn"); + }); +}); diff --git a/client/src/const.ts b/client/src/const.ts index 9999063..608ab94 100644 --- a/client/src/const.ts +++ b/client/src/const.ts @@ -2,8 +2,13 @@ export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; // Generate login URL at runtime so redirect URI reflects the current origin. export const getLoginUrl = () => { - const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL; + const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL?.trim(); const appId = import.meta.env.VITE_APP_ID; + + if (!oauthPortalUrl) { + return "/login"; + } + const redirectUri = `${window.location.origin}/api/oauth/callback`; const state = btoa(redirectUri); diff --git a/deploy/nginx.te.hao.work.conf b/deploy/nginx.te.hao.work.conf index bf2ecd0..45a0838 100644 --- a/deploy/nginx.te.hao.work.conf +++ b/deploy/nginx.te.hao.work.conf @@ -1,21 +1,40 @@ server { listen 80; + listen [::]:80; server_name te.hao.work; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + return 301 https://$host$request_uri; } server { listen 443 ssl http2; + listen [::]:443 ssl http2; server_name te.hao.work; ssl_certificate /etc/letsencrypt/live/te.hao.work/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/te.hao.work/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; client_max_body_size 512m; + add_header Strict-Transport-Security "max-age=15552000" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } location / { - proxy_pass http://127.0.0.1:3000; + proxy_pass http://127.0.0.1:3002; proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -29,6 +48,8 @@ server { proxy_request_buffering off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/docker-compose.yml b/docker-compose.yml index e0280c0..97927ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,40 @@ services: + db: + image: mysql:8.4 + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + environment: + MYSQL_DATABASE: ${MYSQL_DATABASE:-tennis_training_hub} + MYSQL_USER: ${MYSQL_USER:-tennis} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-tennis_password} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change-this-root-password} + volumes: + - db-data:/var/lib/mysql + ports: + - "127.0.0.1:3306:3306" + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"] + interval: 10s + timeout: 5s + retries: 12 + restart: unless-stopped + + migrate: + build: + context: . + dockerfile: Dockerfile + target: build + env_file: + - .env + environment: + DATABASE_URL: mysql://${MYSQL_USER:-tennis}:${MYSQL_PASSWORD:-tennis_password}@db:3306/${MYSQL_DATABASE:-tennis_training_hub} + command: ["pnpm", "exec", "drizzle-kit", "migrate"] + depends_on: + db: + condition: service_healthy + restart: "no" + app: build: context: . @@ -7,12 +43,21 @@ services: - .env environment: PORT: 3000 + DATABASE_URL: mysql://${MYSQL_USER:-tennis}:${MYSQL_PASSWORD:-tennis_password}@db:3306/${MYSQL_DATABASE:-tennis_training_hub} MEDIA_SERVICE_URL: http://media:8081 + LOCAL_STORAGE_DIR: /data/app/storage NODE_ENV: production ports: - - "3000:3000" + - "127.0.0.1:3002:3000" + volumes: + - app-data:/data/app depends_on: - - media + db: + condition: service_healthy + migrate: + condition: service_completed_successfully + media: + condition: service_started restart: unless-stopped media: @@ -24,7 +69,7 @@ services: MEDIA_DATA_DIR: /data/media MEDIA_EMBEDDED_WORKER: "0" ports: - - "8081:8081" + - "127.0.0.1:8081:8081" volumes: - media-data:/data/media restart: unless-stopped @@ -44,4 +89,6 @@ services: restart: unless-stopped volumes: + app-data: + db-data: media-data: diff --git a/docs/deploy.md b/docs/deploy.md index d81d3ce..8442b37 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -3,9 +3,13 @@ ## Topology - 宿主机 nginx:负责 `te.hao.work` 的 TLS、反向代理与大文件上传入口 +- `db` 容器:MySQL 8,数据持久化到 `db-data` +- `migrate` 容器:一次性执行 Drizzle 迁移,成功后退出 - `app` 容器:Node 应用,端口 `3000` - `media` 容器:Go 媒体服务,端口 `8081` - `worker` 容器:Go 媒体归档 worker,共享媒体卷 +- `app-data` 卷:上传视频等本地文件存储 +- `db-data` 卷:MySQL 数据目录 - `media-data` 卷:录制片段、会话状态、归档成片 ## Required files @@ -21,13 +25,20 @@ cp .env.example .env docker compose up -d --build ``` +建议在 `.env` 中至少设置: + +- `JWT_SECRET` +- `MYSQL_PASSWORD` +- `MYSQL_ROOT_PASSWORD` +- `LLM_API_KEY` + ## nginx 将 `deploy/nginx.te.hao.work.conf` 放到宿主机 nginx 站点目录,确认: - `ssl_certificate` - `ssl_certificate_key` -- `proxy_pass http://127.0.0.1:3000` 对应前端与业务 API +- `proxy_pass http://127.0.0.1:3002` 对应前端、业务 API 和 `/uploads/*` - `proxy_pass http://127.0.0.1:8081` 对应媒体服务 启用后重载 nginx: @@ -39,7 +50,7 @@ systemctl reload nginx ## Health checks -- `curl http://127.0.0.1:3000/api/trpc/auth.me` +- `curl http://127.0.0.1:3002/api/trpc/auth.me` - `curl http://127.0.0.1:8081/media/health` ## Persistent data @@ -51,6 +62,8 @@ systemctl reload nginx - `public/sessions//recording.webm` - `public/sessions//recording.mp4` +应用本地上传文件默认位于 Docker volume `app-data` 下的 `/data/app/storage`。 + ## Rollback 1. 保留 `.env` 和 `media-data` diff --git a/docs/developer-workflow.md b/docs/developer-workflow.md index 009af3f..a894eb3 100644 --- a/docs/developer-workflow.md +++ b/docs/developer-workflow.md @@ -35,9 +35,20 @@ git commit -m "..." 3. 再跑 `pnpm test` 4. 若涉及媒体链路,再跑 `pnpm test:go` 5. 最后跑 `pnpm test:e2e` +6. 若当前分支包含部署改动,再执行 `docker compose config` 与基础 smoke check 不要在一半状态下长时间保留“能编译但主流程不可用”的改动。 +## Deployment-safe checks + +涉及 compose、nginx、数据库或媒体服务调整时,提交前至少确认: + +- `docker compose config` 可通过 +- `docker compose ps` 中 `app`、`db`、`media`、`worker` 正常 +- 一次性迁移容器 `migrate` 成功退出 +- `curl -I https://te.hao.work/` 返回 `200` +- `curl http://127.0.0.1:8081/media/health` 返回 `{"ok":true,...}` + ## Media-related changes 修改录制链路时至少检查: diff --git a/docs/testing.md b/docs/testing.md index c15586d..466b041 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -75,6 +75,24 @@ pnpm test:llm -- "你好,做个自我介绍" - 适合验证 `LLM_API_KEY`、`LLM_MODEL` 和网关连通性 - 不建议纳入 `pnpm verify`,因为它依赖外部网络和真实密钥 +## Production smoke checks + +部署到宿主机后,建议至少补以下联测: + +```bash +docker compose ps +curl -I https://te.hao.work/ +curl http://127.0.0.1:8081/media/health +pnpm test:llm +``` + +推荐再增加一轮浏览器级检查: + +- 打开 `https://te.hao.work/` +- 打开 `https://te.hao.work/login` +- 打开 `https://te.hao.work/recorder` +- 确认没有 `pageerror` 或首屏 `console.error` + ## Local browser prerequisites 首次运行 Playwright 前执行: diff --git a/docs/verified-features.md b/docs/verified-features.md index ef3f80b..9e382e5 100644 --- a/docs/verified-features.md +++ b/docs/verified-features.md @@ -1,12 +1,23 @@ # Verified Features -本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-14 21:44 CST。 +本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-14 22:24 CST。 ## 最新完整验证记录 - 通过命令:`pnpm verify` -- 验证时间:2026-03-14 21:44 CST -- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(69/69),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(5/5) +- 验证时间:2026-03-14 22:23 CST +- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(74/74),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(5/5) + +## 生产部署联测 + +| 项目 | 验证方式 | 状态 | +|------|----------|------| +| `https://te.hao.work/` HTTPS 访问 | `curl -I https://te.hao.work/` | 通过 | +| 站点 TLS 证书 | Let’s Encrypt ECDSA 证书已签发并由宿主机 nginx 加载 | 通过 | +| 生产首页、登录页、录制页浏览器打开 | Playwright 访问 `https://te.hao.work/`、`/login`、`/recorder` | 通过 | +| 生产前端运行时异常检查 | Playwright `pageerror` / `console.error` 检查 | 通过 | +| 媒体健康检查 | `curl http://127.0.0.1:8081/media/health` | 通过 | +| compose 自包含服务 | `docker compose ps` 中 `app` / `db` / `media` / `worker` 正常运行,`migrate` 成功退出 | 通过 | ## 构建与编译通过 @@ -32,6 +43,7 @@ | leaderboard | `pnpm test` | 通过 | | tutorial / reminder / notification 路由校验 | `pnpm test` | 通过 | | media 工具函数 | `pnpm test` | 通过 | +| 登录 URL 回退逻辑 | `pnpm test` | 通过 | ## Go 媒体服务验证 @@ -53,6 +65,13 @@ | 在线录制 | 启动摄像头、开始录制、手动标记、结束归档 | 通过 | | 录制结果入库 | 归档完成后视频库可见录制结果 | 通过 | +## LLM 模块验证 + +| 项目 | 验证方式 | 状态 | +|------|----------|------| +| `.env` 中的 `LLM_API_URL` / `LLM_API_KEY` / `LLM_MODEL` | `pnpm test:llm` | 通过 | +| `https://one.hao.work/v1/chat/completions` 联通性 | `pnpm test:llm` 实际返回文本 | 通过 | + ## 已知非阻断警告 - 测试与开发日志中会出现 `OAUTH_SERVER_URL` 未配置提示;当前 mocked auth 和本地验证链路不依赖真实 OAuth 服务,因此不会导致失败 diff --git a/package.json b/package.json index a9dc2db..9761f9d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.693.0", "@aws-sdk/s3-request-presigner": "^3.693.0", + "@builder.io/vite-plugin-jsx-loc": "^0.1.1", "@hookform/resolvers": "^5.2.2", "@mediapipe/drawing_utils": "^0.3.1675466124", "@mediapipe/pose": "^0.5.1675469404", @@ -50,9 +51,11 @@ "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", + "@tailwindcss/vite": "^4.1.3", "@trpc/client": "^11.6.0", "@trpc/react-query": "^11.6.0", "@trpc/server": "^11.6.0", + "@vitejs/plugin-react": "^5.0.4", "axios": "^1.12.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -82,20 +85,19 @@ "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", + "vite": "^7.1.7", + "vite-plugin-manus-runtime": "^0.0.57", "wouter": "^3.3.5", "zod": "^4.1.12" }, "devDependencies": { - "@builder.io/vite-plugin-jsx-loc": "^0.1.1", "@playwright/test": "^1.55.0", "@tailwindcss/typography": "^0.5.15", - "@tailwindcss/vite": "^4.1.3", "@types/express": "4.17.21", "@types/google.maps": "^3.58.1", "@types/node": "^24.7.0", "@types/react": "^19.2.1", "@types/react-dom": "^19.2.1", - "@vitejs/plugin-react": "^5.0.4", "add": "^2.0.6", "autoprefixer": "^10.4.20", "drizzle-kit": "^0.31.4", @@ -108,8 +110,6 @@ "tsx": "^4.19.1", "tw-animate-css": "^1.4.0", "typescript": "5.9.3", - "vite": "^7.1.7", - "vite-plugin-manus-runtime": "^0.0.57", "vitest": "^2.1.4" }, "packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4efccc8..fd9d3c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.693.0 version: 3.907.0 + '@builder.io/vite-plugin-jsx-loc': + specifier: ^0.1.1 + version: 0.1.1(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.64.0(react@19.2.1)) @@ -109,6 +112,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@tailwindcss/vite': + specifier: ^4.1.3 + version: 4.1.14(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.2(react@19.2.1) @@ -121,6 +127,9 @@ importers: '@trpc/server': specifier: ^11.6.0 version: 11.6.0(typescript@5.9.3) + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 5.0.4(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) axios: specifier: ^1.12.0 version: 1.12.2 @@ -208,6 +217,12 @@ importers: vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + vite: + specifier: ^7.1.7 + version: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6) + vite-plugin-manus-runtime: + specifier: ^0.0.57 + version: 0.0.57 wouter: specifier: ^3.3.5 version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1) @@ -215,18 +230,12 @@ importers: specifier: ^4.1.12 version: 4.1.12 devDependencies: - '@builder.io/vite-plugin-jsx-loc': - specifier: ^0.1.1 - version: 0.1.1(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) '@playwright/test': specifier: ^1.55.0 version: 1.58.2 '@tailwindcss/typography': specifier: ^0.5.15 version: 0.5.19(tailwindcss@4.1.14) - '@tailwindcss/vite': - specifier: ^4.1.3 - version: 4.1.14(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) '@types/express': specifier: 4.17.21 version: 4.17.21 @@ -242,9 +251,6 @@ importers: '@types/react-dom': specifier: ^19.2.1 version: 19.2.1(@types/react@19.2.1) - '@vitejs/plugin-react': - specifier: ^5.0.4 - version: 5.0.4(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) add: specifier: ^2.0.6 version: 2.0.6 @@ -281,12 +287,6 @@ importers: typescript: specifier: 5.9.3 version: 5.9.3 - vite: - specifier: ^7.1.7 - version: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6) - vite-plugin-manus-runtime: - specifier: ^0.0.57 - version: 0.0.57 vitest: specifier: ^2.1.4 version: 2.1.9(@types/node@24.7.0)(jsdom@28.1.0)(lightningcss@1.30.1) diff --git a/server/_core/env.ts b/server/_core/env.ts index f1a24a1..ec3f41c 100644 --- a/server/_core/env.ts +++ b/server/_core/env.ts @@ -18,6 +18,7 @@ export const ENV = { isProduction: process.env.NODE_ENV === "production", forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "", forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "", + localStorageDir: process.env.LOCAL_STORAGE_DIR ?? "./data/storage", llmApiUrl: process.env.LLM_API_URL ?? (process.env.BUILT_IN_FORGE_API_URL diff --git a/server/_core/index.ts b/server/_core/index.ts index b949711..7841c7d 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -2,12 +2,13 @@ import "dotenv/config"; import express from "express"; import { createServer } from "http"; import net from "net"; +import path from "node:path"; import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { registerOAuthRoutes } from "./oauth"; import { appRouter } from "../routers"; import { createContext } from "./context"; import { registerMediaProxy } from "./mediaProxy"; -import { serveStatic, setupVite } from "./vite"; +import { serveStatic } from "./static"; function isPortAvailable(port: number): Promise { return new Promise(resolve => { @@ -35,6 +36,10 @@ async function startServer() { // Configure body parser with larger size limit for file uploads app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ limit: "50mb", extended: true })); + app.use( + "/uploads", + express.static(path.resolve(process.env.LOCAL_STORAGE_DIR || "data/storage")) + ); // OAuth callback under /api/oauth/callback registerOAuthRoutes(app); // tRPC API @@ -47,6 +52,7 @@ async function startServer() { ); // development mode uses Vite, production mode uses static files if (process.env.NODE_ENV === "development") { + const { setupVite } = await import("./vite"); await setupVite(app, server); } else { serveStatic(app); diff --git a/server/_core/static.ts b/server/_core/static.ts new file mode 100644 index 0000000..7a45f3a --- /dev/null +++ b/server/_core/static.ts @@ -0,0 +1,21 @@ +import express, { type Express } from "express"; +import fs from "fs"; +import path from "path"; + +export function serveStatic(app: Express) { + const distPath = + process.env.NODE_ENV === "development" + ? path.resolve(import.meta.dirname, "../..", "dist", "public") + : path.resolve(import.meta.dirname, "public"); + if (!fs.existsSync(distPath)) { + console.error( + `Could not find the build directory: ${distPath}, make sure to build the client first` + ); + } + + app.use(express.static(distPath)); + + app.use("*", (_req, res) => { + res.sendFile(path.resolve(distPath, "index.html")); + }); +} diff --git a/server/_core/vite.ts b/server/_core/vite.ts index 86d07f6..d09df20 100644 --- a/server/_core/vite.ts +++ b/server/_core/vite.ts @@ -46,22 +46,3 @@ export async function setupVite(app: Express, server: Server) { } }); } - -export function serveStatic(app: Express) { - const distPath = - process.env.NODE_ENV === "development" - ? path.resolve(import.meta.dirname, "../..", "dist", "public") - : path.resolve(import.meta.dirname, "public"); - if (!fs.existsSync(distPath)) { - console.error( - `Could not find the build directory: ${distPath}, make sure to build the client first` - ); - } - - app.use(express.static(distPath)); - - // fall through to index.html if the file doesn't exist - app.use("*", (_req, res) => { - res.sendFile(path.resolve(distPath, "index.html")); - }); -} diff --git a/server/storage.test.ts b/server/storage.test.ts new file mode 100644 index 0000000..44ea28a --- /dev/null +++ b/server/storage.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const ORIGINAL_ENV = { ...process.env }; + +describe("storage fallback", () => { + let tempDir: string; + + beforeEach(async () => { + vi.resetModules(); + process.env = { ...ORIGINAL_ENV }; + delete process.env.BUILT_IN_FORGE_API_URL; + delete process.env.BUILT_IN_FORGE_API_KEY; + tempDir = await mkdtemp(path.join(os.tmpdir(), "tennis-storage-")); + process.env.LOCAL_STORAGE_DIR = tempDir; + }); + + afterEach(async () => { + process.env = { ...ORIGINAL_ENV }; + await rm(tempDir, { recursive: true, force: true }); + }); + + it("stores files locally when remote storage is not configured", async () => { + const { storagePut, storageGet } = await import("./storage"); + + const stored = await storagePut("videos/test/sample.webm", Buffer.from("demo")); + const loaded = await storageGet("videos/test/sample.webm"); + + expect(stored).toEqual({ + key: "videos/test/sample.webm", + url: "/uploads/videos/test/sample.webm", + }); + expect(loaded).toEqual({ + key: "videos/test/sample.webm", + url: "/uploads/videos/test/sample.webm", + }); + }); +}); diff --git a/server/storage.ts b/server/storage.ts index 6cdf862..73dbdd4 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -1,6 +1,8 @@ // Preconfigured storage helpers for Manus WebDev templates // Uses the Biz-provided storage proxy (Authorization: Bearer ) +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; import { ENV } from './_core/env'; type StorageConfig = { baseUrl: string; apiKey: string }; @@ -18,6 +20,31 @@ function getStorageConfig(): StorageConfig { return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey }; } +function canUseRemoteStorage(): boolean { + return Boolean(ENV.forgeApiUrl && ENV.forgeApiKey); +} + +function getLocalStoragePath(relKey: string): string { + return path.join(ENV.localStorageDir, normalizeKey(relKey)); +} + +async function writeLocalFile( + relKey: string, + data: Buffer | Uint8Array | string +): Promise { + const filePath = getLocalStoragePath(relKey); + await mkdir(path.dirname(filePath), { recursive: true }); + const content = + typeof data === "string" ? Buffer.from(data) : Buffer.from(data); + await writeFile(filePath, content); +} + +async function readLocalFile(relKey: string): Promise { + const filePath = getLocalStoragePath(relKey); + await readFile(filePath); + return `/uploads/${normalizeKey(relKey)}`; +} + function buildUploadUrl(baseUrl: string, relKey: string): URL { const url = new URL("v1/storage/upload", ensureTrailingSlash(baseUrl)); url.searchParams.set("path", normalizeKey(relKey)); @@ -72,6 +99,12 @@ export async function storagePut( data: Buffer | Uint8Array | string, contentType = "application/octet-stream" ): Promise<{ key: string; url: string }> { + if (!canUseRemoteStorage()) { + const key = normalizeKey(relKey); + await writeLocalFile(key, data); + return { key, url: `/uploads/${key}` }; + } + const { baseUrl, apiKey } = getStorageConfig(); const key = normalizeKey(relKey); const uploadUrl = buildUploadUrl(baseUrl, key); @@ -93,6 +126,14 @@ export async function storagePut( } export async function storageGet(relKey: string): Promise<{ key: string; url: string; }> { + if (!canUseRemoteStorage()) { + const key = normalizeKey(relKey); + return { + key, + url: await readLocalFile(key), + }; + } + const { baseUrl, apiKey } = getStorageConfig(); const key = normalizeKey(relKey); return {