fix: route IM clients through current origin
Deploy im-webapp production / deploy (push) Successful in 1m35s
Deploy im-webapp production / deploy (push) Successful in 1m35s
This commit is contained in:
@@ -45,8 +45,6 @@ jobs:
|
|||||||
TINODE_API_KEY="${TINODE_API_KEY%\'}"
|
TINODE_API_KEY="${TINODE_API_KEY%\'}"
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg TINODE_API_KEY="${TINODE_API_KEY}" \
|
--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}" .
|
-t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
|
||||||
docker push "$IMAGE:pro"
|
docker push "$IMAGE:pro"
|
||||||
docker push "$IMAGE:$SHORT_SHA"
|
docker push "$IMAGE:$SHORT_SHA"
|
||||||
|
|||||||
+3
-3
@@ -19,9 +19,9 @@ ENV VITE_TINODE_API_KEY=$TINODE_API_KEY
|
|||||||
ARG TINODE_API_BASE=
|
ARG TINODE_API_BASE=
|
||||||
ENV VITE_TINODE_API_BASE=$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
|
# Leave the file origin unset in production so downloads use the current site's /v0/file route.
|
||||||
# deployed path router and may otherwise send /v0/file requests to an older IM server.
|
# A build-time override remains available for isolated development deployments.
|
||||||
ARG TINODE_FILE_BASE=https://api.118im.com/v0/file
|
ARG TINODE_FILE_BASE=
|
||||||
ENV VITE_TINODE_FILE_BASE=$TINODE_FILE_BASE
|
ENV VITE_TINODE_FILE_BASE=$TINODE_FILE_BASE
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -38,9 +38,12 @@ class DeployWorkflowContractTest(unittest.TestCase):
|
|||||||
self.assertNotIn("KUBECONFIG", self.workflow)
|
self.assertNotIn("KUBECONFIG", self.workflow)
|
||||||
self.assertNotIn("git.118im.com", self.workflow)
|
self.assertNotIn("git.118im.com", self.workflow)
|
||||||
|
|
||||||
def test_uses_production_im_endpoint(self):
|
def test_uses_same_origin_im_endpoints(self):
|
||||||
self.assertIn('TINODE_API_BASE="https://im-api.yuexuan.shop/v1/im"', self.workflow)
|
self.assertNotIn("TINODE_API_BASE=", self.workflow)
|
||||||
self.assertIn('TINODE_FILE_BASE="https://im-api.yuexuan.shop/v0/file"', 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):
|
def test_build_exposes_configured_api_base_to_vite(self):
|
||||||
self.assertIn("ARG TINODE_API_BASE=", self.dockerfile)
|
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)
|
||||||
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("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("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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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 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
|
// 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
|
// secret (base64 username:password) and full plaintext message content -- dev-only, never in a
|
||||||
|
|||||||
@@ -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<T extends TenantEndpointContext>(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`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
import { migrateFixedTenant } from './session-migration';
|
||||||
|
|
||||||
interface TenantContext {
|
interface TenantContext {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,6 +25,11 @@ interface SessionState {
|
|||||||
setMyUserId: (id: string | null) => void;
|
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
|
* 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
|
* 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<SessionState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
tenant: null,
|
tenant: null,
|
||||||
myUserId: null,
|
myUserId: null,
|
||||||
setTenant: (tenant) => set({ tenant }),
|
setTenant: (tenant) => set({ tenant: normalizeTenant(tenant) }),
|
||||||
clearTenant: () => set({ tenant: null }),
|
clearTenant: () => set({ tenant: null }),
|
||||||
setMyUserId: (id) => set({ myUserId: id }),
|
setMyUserId: (id) => set({ myUserId: id }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'meetingim-session',
|
name: 'meetingim-session',
|
||||||
partialize: (state) => ({ tenant: state.tenant }),
|
partialize: (state) => ({ tenant: state.tenant }),
|
||||||
|
merge: (persisted, current) => {
|
||||||
|
const tenant = (persisted as Partial<SessionState> | undefined)?.tenant;
|
||||||
|
return { ...current, tenant: tenant ? normalizeTenant(tenant) : null };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,3 +22,16 @@ test('login page exposes no tenant switch and rejects stale tenant state', async
|
|||||||
assert.match(login, /企业码:\{FIXED_TENANT_CODE\}/);
|
assert.match(login, /企业码:\{FIXED_TENANT_CODE\}/);
|
||||||
assert.doesNotMatch(login, />\s*切换企业\s*</);
|
assert.doesNotMatch(login, />\s*切换企业\s*</);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('websocket fallback uses the current browser host', async () => {
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
+3
-7
@@ -19,16 +19,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: process.env.PORT ? Number(process.env.PORT) : 8094,
|
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
|
// Same-origin dev proxy for REST, files, and Tinode WebSocket. Production uses the same paths
|
||||||
// sees http://localhost:8094) so a resolved tenant's *relative* endpoints ("/v1/im", "/v0/file")
|
// through Caddy, so local development must expose all three routes too.
|
||||||
// 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).
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/v1/im': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false },
|
'/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/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 },
|
'/legal': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user