From df173879fa3ca7cca75676e4c942000f34aa889d Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 19 Oct 2026 17:23:34 +0800 Subject: [PATCH] fix: route IM clients through current origin --- .gitea/workflows/deploy.yml | 2 -- Dockerfile | 6 ++-- tests/test_actions_workflow.py | 13 +++++--- web/src/lib/config.ts | 2 +- web/src/stores/session-migration.ts | 27 ++++++++++++++++ web/src/stores/session.ts | 13 +++++++- web/tests/fixed-tenant.test.mjs | 13 ++++++++ web/tests/session-migration.test.ts | 48 +++++++++++++++++++++++++++++ web/vite.config.ts | 10 ++---- 9 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 web/src/stores/session-migration.ts create mode 100644 web/tests/session-migration.test.ts diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 09606e25..74e14dcc 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -45,8 +45,6 @@ jobs: TINODE_API_KEY="${TINODE_API_KEY%\'}" docker build \ --build-arg TINODE_API_KEY="${TINODE_API_KEY}" \ - --build-arg TINODE_API_BASE="https://im-api.yuexuan.shop/v1/im" \ - --build-arg TINODE_FILE_BASE="https://im-api.yuexuan.shop/v0/file" \ -t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" . docker push "$IMAGE:pro" docker push "$IMAGE:$SHORT_SHA" diff --git a/Dockerfile b/Dockerfile index f47a371e..769f3277 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,9 +19,9 @@ ENV VITE_TINODE_API_KEY=$TINODE_API_KEY ARG TINODE_API_BASE= ENV VITE_TINODE_API_BASE=$TINODE_API_BASE -# File downloads must use the canonical IM API origin. The web origin has its own independently -# deployed path router and may otherwise send /v0/file requests to an older IM server. -ARG TINODE_FILE_BASE=https://api.118im.com/v0/file +# Leave the file origin unset in production so downloads use the current site's /v0/file route. +# A build-time override remains available for isolated development deployments. +ARG TINODE_FILE_BASE= ENV VITE_TINODE_FILE_BASE=$TINODE_FILE_BASE RUN npm run build diff --git a/tests/test_actions_workflow.py b/tests/test_actions_workflow.py index f0e9c829..302e68aa 100644 --- a/tests/test_actions_workflow.py +++ b/tests/test_actions_workflow.py @@ -38,9 +38,12 @@ class DeployWorkflowContractTest(unittest.TestCase): self.assertNotIn("KUBECONFIG", self.workflow) self.assertNotIn("git.118im.com", self.workflow) - def test_uses_production_im_endpoint(self): - self.assertIn('TINODE_API_BASE="https://im-api.yuexuan.shop/v1/im"', self.workflow) - self.assertIn('TINODE_FILE_BASE="https://im-api.yuexuan.shop/v0/file"', self.workflow) + def test_uses_same_origin_im_endpoints(self): + self.assertNotIn("TINODE_API_BASE=", self.workflow) + self.assertNotIn("TINODE_FILE_BASE=", self.workflow) + self.assertNotIn("im-api.yuexuan.shop", self.workflow) + self.assertIn("ARG TINODE_FILE_BASE=", self.dockerfile) + self.assertNotIn("api.118im.com", self.dockerfile) def test_build_exposes_configured_api_base_to_vite(self): self.assertIn("ARG TINODE_API_BASE=", self.dockerfile) @@ -50,9 +53,11 @@ class DeployWorkflowContractTest(unittest.TestCase): self.assertIn('TINODE_API_KEY="${TINODE_API_KEY#\\\'}"', self.workflow) self.assertIn('TINODE_API_KEY="${TINODE_API_KEY%\\\'}"', self.workflow) - def test_api_client_prefers_the_configured_production_origin(self): + def test_api_client_keeps_same_origin_defaults(self): self.assertIn("import.meta.env.VITE_TINODE_API_BASE?.trim()", self.api_client) self.assertIn("CONFIGURED_API_BASE || endpoints?.api || DEFAULT_API_BASE", self.api_client) + self.assertIn("const DEFAULT_API_BASE = '/v1/im'", self.api_client) + self.assertIn("const DEFAULT_FILE_BASE = '/v0/file'", self.api_client) if __name__ == "__main__": unittest.main() diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 077b0594..44a98dc8 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -19,7 +19,7 @@ export const APP_NAME = `MeetingIM/${__APP_VERSION__}`; */ export const API_KEY = import.meta.env.VITE_TINODE_API_KEY || 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K'; -export const DEFAULT_HOST = 'web.118im.com'; +export const DEFAULT_HOST = typeof window !== 'undefined' && window.location?.host ? window.location.host : 'localhost'; // Tinode's own verbose logging prints every WS packet to the console, including the login // secret (base64 username:password) and full plaintext message content -- dev-only, never in a diff --git a/web/src/stores/session-migration.ts b/web/src/stores/session-migration.ts new file mode 100644 index 00000000..067bff7c --- /dev/null +++ b/web/src/stores/session-migration.ts @@ -0,0 +1,27 @@ +export interface TenantEndpointContext { + code: string; + endpoints: { api: string; file: string }; + host: string; + secure: boolean; +} + +export interface BrowserLocationContext { + origin: string; + host: string; + protocol: string; +} + +/** Keep the fixed production tenant on the frontend's own reverse-proxy origin. */ +export function migrateFixedTenant(tenant: T, location: BrowserLocationContext): T { + if (tenant.code.trim().toLowerCase() !== 'default') return tenant; + const origin = location.origin.replace(/\/$/, ''); + return { + ...tenant, + host: location.host, + secure: location.protocol === 'https:', + endpoints: { + api: `${origin}/v1/im`, + file: `${origin}/v0/file`, + }, + }; +} diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index 4adc0301..f0c266c0 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -1,6 +1,8 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { migrateFixedTenant } from './session-migration'; + interface TenantContext { code: string; name: string; @@ -23,6 +25,11 @@ interface SessionState { setMyUserId: (id: string | null) => void; } +function normalizeTenant(tenant: TenantContext): TenantContext { + if (typeof window === 'undefined') return tenant; + return migrateFixedTenant(tenant, window.location); +} + /** * Tenant context persists across browser restarts (localStorage) the same way the old app kept * it, since a returning user's auto-login still needs it to build the {hi} packet and every REST @@ -36,13 +43,17 @@ export const useSessionStore = create()( (set) => ({ tenant: null, myUserId: null, - setTenant: (tenant) => set({ tenant }), + setTenant: (tenant) => set({ tenant: normalizeTenant(tenant) }), clearTenant: () => set({ tenant: null }), setMyUserId: (id) => set({ myUserId: id }), }), { name: 'meetingim-session', partialize: (state) => ({ tenant: state.tenant }), + merge: (persisted, current) => { + const tenant = (persisted as Partial | undefined)?.tenant; + return { ...current, tenant: tenant ? normalizeTenant(tenant) : null }; + }, }, ), ); diff --git a/web/tests/fixed-tenant.test.mjs b/web/tests/fixed-tenant.test.mjs index 581d3578..23ad6f33 100644 --- a/web/tests/fixed-tenant.test.mjs +++ b/web/tests/fixed-tenant.test.mjs @@ -22,3 +22,16 @@ test('login page exposes no tenant switch and rejects stale tenant state', async assert.match(login, /企业码:\{FIXED_TENANT_CODE\}/); assert.doesNotMatch(login, />\s*切换企业\s* { + const config = await source('src/lib/config.ts'); + + assert.match(config, /window\.location\.host/); + assert.doesNotMatch(config, /web\.118im\.com/); +}); + +test('local development proxies the same-origin websocket path', async () => { + const vite = await source('vite.config.ts'); + + assert.match(vite, /['"]\/v0\/channels['"]\s*:\s*\{[^}]*ws:\s*true/); +}); diff --git a/web/tests/session-migration.test.ts b/web/tests/session-migration.test.ts new file mode 100644 index 00000000..fc6f94f8 --- /dev/null +++ b/web/tests/session-migration.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { migrateFixedTenant } from '../src/stores/session-migration.ts'; + +test('migrates a persisted default tenant to the current frontend origin', () => { + const migrated = migrateFixedTenant( + { + code: 'default', + name: 'Default tenant', + host: 'im-api.yuexuan.shop', + secure: true, + endpoints: { + api: 'https://im-api.yuexuan.shop/v1/im', + file: 'https://im-api.yuexuan.shop/v0/file', + }, + passwordPolicy: { minLength: 8, maxLength: 64 }, + }, + { origin: 'https://www.yuexuan2.shop', host: 'www.yuexuan2.shop', protocol: 'https:' }, + ); + + assert.deepEqual(migrated, { + code: 'default', + name: 'Default tenant', + host: 'www.yuexuan2.shop', + secure: true, + endpoints: { + api: 'https://www.yuexuan2.shop/v1/im', + file: 'https://www.yuexuan2.shop/v0/file', + }, + passwordPolicy: { minLength: 8, maxLength: 64 }, + }); +}); + +test('does not rewrite a non-default tenant', () => { + const tenant = { + code: 'enterprise-a', + name: 'Enterprise A', + host: 'tenant.example.com', + secure: true, + endpoints: { api: 'https://tenant.example.com/v1/im', file: 'https://tenant.example.com/v0/file' }, + }; + + assert.equal( + migrateFixedTenant(tenant, { origin: 'https://www.yuexuan1.shop', host: 'www.yuexuan1.shop', protocol: 'https:' }), + tenant, + ); +}); diff --git a/web/vite.config.ts b/web/vite.config.ts index cb3eb038..bcf2497e 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -19,16 +19,12 @@ export default defineConfig({ }, server: { port: process.env.PORT ? Number(process.env.PORT) : 8094, - // Same-origin dev proxy for the REST layer only -- avoids CORS entirely (the browser only ever - // sees http://localhost:8094) so a resolved tenant's *relative* endpoints ("/v1/im", "/v0/file") - // work unmodified in local dev, same idea as the old app's hand-rolled dev-proxy-server.js, just - // using Vite's built-in proxy instead of a bespoke Node server. The Tinode WS connection itself - // does NOT need this: WebSocket isn't subject to the fetch() same-origin policy, so it connects - // straight to the tenant's real host regardless (confirmed working this way throughout the old - // app's development too). + // Same-origin dev proxy for REST, files, and Tinode WebSocket. Production uses the same paths + // through Caddy, so local development must expose all three routes too. proxy: { '/v1/im': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false }, '/v0/file': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false }, + '/v0/channels': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false, ws: true }, '/legal': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false }, }, },