Compare commits
5
Commits
72c4f4e54d
..
pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df173879fa | ||
|
|
b0d1199fb6 | ||
|
|
c063d056b2 | ||
|
|
d7bfd2f417 | ||
|
|
d2b4cb0440 |
+57
-24
@@ -1,40 +1,73 @@
|
|||||||
name: Deploy im-webapp (k3s test cluster)
|
name: Deploy im-webapp production
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- pro
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
image_tag:
|
concurrency:
|
||||||
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
|
group: production-im-webapp
|
||||||
required: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.olns.shop
|
||||||
|
IMAGE: git.olns.shop/pro/im_webapp
|
||||||
|
SERVICE: im-webapp
|
||||||
|
DEPLOY_HOST: 145.223.69.9
|
||||||
|
DEPLOY_USER: olns-deploy
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: deploy-host
|
runs-on: deploy-host
|
||||||
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: 确定镜像tag
|
- name: Log in to Gitea Container Registry
|
||||||
id: vars
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ github.event.inputs.image_tag }}"
|
set -euo pipefail
|
||||||
[ -z "$TAG" ] && TAG=test
|
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
|
||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: 登录镜像仓库
|
- name: Build and push image
|
||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
|
env:
|
||||||
|
TINODE_API_KEY: ${{ secrets.TINODE_API_KEY }}
|
||||||
- name: 构建并推送镜像
|
GITHUB_SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
|
||||||
|
TINODE_API_KEY="${TINODE_API_KEY#\'}"
|
||||||
|
TINODE_API_KEY="${TINODE_API_KEY%\'}"
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg TINODE_API_KEY="${{ secrets.TINODE_API_KEY }}" \
|
--build-arg TINODE_API_KEY="${TINODE_API_KEY}" \
|
||||||
--build-arg TINODE_FILE_BASE="https://api.118im.com/v0/file" \
|
-t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
|
||||||
-t git.118im.com/im_team/im-webapp:${{ steps.vars.outputs.tag }} .
|
docker push "$IMAGE:pro"
|
||||||
docker push git.118im.com/im_team/im-webapp:${{ steps.vars.outputs.tag }}
|
docker push "$IMAGE:$SHORT_SHA"
|
||||||
|
|
||||||
- name: 部署到k3s测试集群
|
- name: Deploy Compose service
|
||||||
|
env:
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.kube
|
set -euo pipefail
|
||||||
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
|
install -d -m 700 "$HOME/.ssh"
|
||||||
chmod 600 ~/.kube/config
|
KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
|
||||||
kubectl rollout restart deployment/im-webapp -n im-webapp-system
|
cleanup() {
|
||||||
kubectl rollout status deployment/im-webapp -n im-webapp-system --timeout=120s
|
shred -u "$KEY_FILE" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
|
||||||
|
chmod 600 "$KEY_FILE"
|
||||||
|
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
|
||||||
|
chmod 600 "$HOME/.ssh/known_hosts"
|
||||||
|
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
|
||||||
|
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
"docker login git.olns.shop -u admin --password-stdin"
|
||||||
|
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
"cd /opt/olns && docker compose pull im-webapp && docker compose up -d --no-deps --wait im-webapp"
|
||||||
|
|||||||
+8
-3
@@ -14,9 +14,14 @@ COPY web/. .
|
|||||||
ARG TINODE_API_KEY=
|
ARG TINODE_API_KEY=
|
||||||
ENV VITE_TINODE_API_KEY=$TINODE_API_KEY
|
ENV VITE_TINODE_API_KEY=$TINODE_API_KEY
|
||||||
|
|
||||||
# File downloads must use the canonical IM API origin. The web origin has its own independently
|
# The production web UI and IM REST API use different origins. Local development leaves this unset
|
||||||
# deployed path router and may otherwise send /v0/file requests to an older IM server.
|
# and continues to use Vite's same-origin /v1/im proxy.
|
||||||
ARG TINODE_FILE_BASE=https://api.118im.com/v0/file
|
ARG TINODE_API_BASE=
|
||||||
|
ENV VITE_TINODE_API_BASE=$TINODE_API_BASE
|
||||||
|
|
||||||
|
# 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
|
ENV VITE_TINODE_FILE_BASE=$TINODE_FILE_BASE
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
WORKFLOW = Path(__file__).resolve().parents[1] / ".gitea/workflows/deploy.yml"
|
||||||
|
DOCKERFILE = Path(__file__).resolve().parents[1] / "Dockerfile"
|
||||||
|
API_CLIENT = Path(__file__).resolve().parents[1] / "web/src/lib/api/client.ts"
|
||||||
|
|
||||||
|
|
||||||
|
class DeployWorkflowContractTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
cls.dockerfile = DOCKERFILE.read_text(encoding="utf-8")
|
||||||
|
cls.api_client = API_CLIENT.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_targets_pro_branch_and_single_runner(self):
|
||||||
|
self.assertIn("push:", self.workflow)
|
||||||
|
self.assertIn("- pro", self.workflow)
|
||||||
|
self.assertIn("workflow_dispatch:", self.workflow)
|
||||||
|
self.assertIn("runs-on: deploy-host", self.workflow)
|
||||||
|
self.assertIn("concurrency:", self.workflow)
|
||||||
|
|
||||||
|
def test_builds_and_pushes_pro_image(self):
|
||||||
|
self.assertIn("git.olns.shop/pro/im_webapp", self.workflow)
|
||||||
|
self.assertIn("docker push", self.workflow)
|
||||||
|
self.assertIn(":pro", self.workflow)
|
||||||
|
|
||||||
|
def test_deploys_compose_service_with_pinned_host_key(self):
|
||||||
|
self.assertIn("DEPLOY_SSH_KEY", self.workflow)
|
||||||
|
self.assertIn("DEPLOY_KNOWN_HOSTS", self.workflow)
|
||||||
|
self.assertIn("StrictHostKeyChecking=yes", self.workflow)
|
||||||
|
self.assertIn("docker compose pull im-webapp", self.workflow)
|
||||||
|
self.assertIn("docker compose up -d --no-deps --wait im-webapp", self.workflow)
|
||||||
|
|
||||||
|
def test_removes_old_kubernetes_and_registry_targets(self):
|
||||||
|
self.assertNotIn("kubectl", self.workflow)
|
||||||
|
self.assertNotIn("KUBECONFIG", self.workflow)
|
||||||
|
self.assertNotIn("git.118im.com", 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)
|
||||||
|
self.assertIn("ENV VITE_TINODE_API_BASE=$TINODE_API_BASE", self.dockerfile)
|
||||||
|
|
||||||
|
def test_strips_accidentally_quoted_api_key_before_build(self):
|
||||||
|
self.assertIn('TINODE_API_KEY="${TINODE_API_KEY#\\\'}"', self.workflow)
|
||||||
|
self.assertIn('TINODE_API_KEY="${TINODE_API_KEY%\\\'}"', self.workflow)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ import { Link, Navigate, useNavigate } from 'react-router-dom';
|
|||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
||||||
import { BASE_APP_NAME } from '@/lib/config';
|
import { BASE_APP_NAME, FIXED_TENANT_CODE } from '@/lib/config';
|
||||||
import { getTinode, loginBasic } from '@/lib/tinode/connection';
|
import { getTinode, loginBasic } from '@/lib/tinode/connection';
|
||||||
import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
|
import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
@@ -25,7 +25,7 @@ export function LoginPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
if (!tenant) {
|
if (!tenant || tenant.code !== FIXED_TENANT_CODE) {
|
||||||
// Read tenant before touching getTinode() -- getTinode() lazily creates and PERMANENTLY caches
|
// Read tenant before touching getTinode() -- getTinode() lazily creates and PERMANENTLY caches
|
||||||
// the singleton off whatever tenant is in the store at that moment (or a hardcoded fallback
|
// the singleton off whatever tenant is in the store at that moment (or a hardcoded fallback
|
||||||
// host if there's none yet); calling it here first would risk creating that singleton against
|
// host if there's none yet); calling it here first would risk creating that singleton against
|
||||||
@@ -73,10 +73,7 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col bg-white px-6 py-8">
|
<div className="flex flex-1 flex-col bg-white px-6 py-8">
|
||||||
<Link to="/tenant" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
|
<div className="text-sm text-text-secondary">企业码:{FIXED_TENANT_CODE}</div>
|
||||||
<Icon name="arrow_back_ios_new" className="text-[16px]" />
|
|
||||||
切换企业
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center">
|
<div className="flex flex-1 flex-col items-center justify-center">
|
||||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
|
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
|||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { ImApiError } from '@/lib/api/client';
|
import { ImApiError } from '@/lib/api/client';
|
||||||
import { resolveTenant } from '@/lib/api/tenant';
|
import { resolveTenant } from '@/lib/api/tenant';
|
||||||
import { BASE_APP_NAME } from '@/lib/config';
|
import { BASE_APP_NAME, FIXED_TENANT_CODE } from '@/lib/config';
|
||||||
import { resetTinode } from '@/lib/tinode/connection';
|
import { resetTinode } from '@/lib/tinode/connection';
|
||||||
import { requestConfirm } from '@/stores/confirmDialog';
|
import { requestConfirm } from '@/stores/confirmDialog';
|
||||||
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
|
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
|
||||||
@@ -17,13 +17,12 @@ export function TenantEntryPage() {
|
|||||||
const setTenant = useSessionStore((s) => s.setTenant);
|
const setTenant = useSessionStore((s) => s.setTenant);
|
||||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||||
const myUserId = useSessionStore((s) => s.myUserId);
|
const myUserId = useSessionStore((s) => s.myUserId);
|
||||||
const [code, setCode] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!code.trim() || submitting) return;
|
if (submitting) return;
|
||||||
// Reachable by navigating back here while already logged in (no route guard keeps an
|
// Reachable by navigating back here while already logged in (no route guard keeps an
|
||||||
// authenticated user out of /tenant) -- resetTinode() below would otherwise silently tear down
|
// authenticated user out of /tenant) -- resetTinode() below would otherwise silently tear down
|
||||||
// that live session with no warning at all. `myUserId` alone isn't enough: it's an in-memory
|
// that live session with no warning at all. `myUserId` alone isn't enough: it's an in-memory
|
||||||
@@ -33,11 +32,11 @@ export function TenantEntryPage() {
|
|||||||
// RequireAuth.tsx, which is the ONLY place that re-populates `myUserId` from that token, and it
|
// RequireAuth.tsx, which is the ONLY place that re-populates `myUserId` from that token, and it
|
||||||
// never mounts for this route. Checking the token directly closes that gap.
|
// never mounts for this route. Checking the token directly closes that gap.
|
||||||
const hasLiveSession = myUserId || sessionStorage.getItem('im-session-token');
|
const hasLiveSession = myUserId || sessionStorage.getItem('im-session-token');
|
||||||
if (hasLiveSession && !(await requestConfirm({ message: '切换企业将退出当前登录,确定继续吗?' }))) return;
|
if (hasLiveSession && !(await requestConfirm({ message: '重新进入将退出当前登录,确定继续吗?' }))) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const res = await resolveTenant(code.trim());
|
const res = await resolveTenant(FIXED_TENANT_CODE);
|
||||||
const { host, secure } = parseWebsocketEndpoint(res.endpoints.websocket);
|
const { host, secure } = parseWebsocketEndpoint(res.endpoints.websocket);
|
||||||
// Without this, switching tenants mid-session silently keeps talking to the OLD tenant's
|
// Without this, switching tenants mid-session silently keeps talking to the OLD tenant's
|
||||||
// host: getTinode() lazily creates one singleton and reuses it forever once created, and
|
// host: getTinode() lazily creates one singleton and reuses it forever once created, and
|
||||||
@@ -73,7 +72,7 @@ export function TenantEntryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-dvh w-full items-center justify-center bg-surface-page px-6">
|
<div className="relative flex h-dvh w-full items-center justify-center bg-surface-page px-6">
|
||||||
<Link to="/enterprise-register" state={{ tenantCodeHint: code.trim() }} className="absolute right-6 top-6 text-sm text-accent no-underline">
|
<Link to="/enterprise-register" state={{ tenantCodeHint: FIXED_TENANT_CODE }} className="absolute right-6 top-6 text-sm text-accent no-underline">
|
||||||
企业注册
|
企业注册
|
||||||
</Link>
|
</Link>
|
||||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-5">
|
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-5">
|
||||||
@@ -90,22 +89,21 @@ export function TenantEntryPage() {
|
|||||||
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
|
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
|
||||||
<Icon name="business" className="text-[20px] text-focus-border" />
|
<Icon name="business" className="text-[20px] text-focus-border" />
|
||||||
<input
|
<input
|
||||||
value={code}
|
value={FIXED_TENANT_CODE}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
readOnly
|
||||||
placeholder="请输入企业码"
|
aria-label="企业码"
|
||||||
maxLength={64}
|
className="w-full cursor-not-allowed bg-transparent text-[15px] text-text-secondary outline-none"
|
||||||
className="w-full bg-transparent text-[15px] outline-none"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!code.trim() || submitting}
|
disabled={submitting}
|
||||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||||
>
|
>
|
||||||
{submitting ? '进入中…' : '进入企业'}
|
{submitting ? '进入中…' : '进入企业'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<Link to="/feedback" state={{ tenantCodeHint: code.trim() }} className="absolute bottom-8 text-sm text-accent no-underline">
|
<Link to="/feedback" state={{ tenantCodeHint: FIXED_TENANT_CODE }} className="absolute bottom-8 text-sm text-accent no-underline">
|
||||||
遇到问题?
|
遇到问题?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useSessionStore } from '@/stores/session';
|
|||||||
|
|
||||||
const DEFAULT_API_BASE = '/v1/im';
|
const DEFAULT_API_BASE = '/v1/im';
|
||||||
const DEFAULT_FILE_BASE = '/v0/file';
|
const DEFAULT_FILE_BASE = '/v0/file';
|
||||||
|
const CONFIGURED_API_BASE = import.meta.env.VITE_TINODE_API_BASE?.trim();
|
||||||
const CONFIGURED_FILE_BASE = import.meta.env.VITE_TINODE_FILE_BASE?.trim();
|
const CONFIGURED_FILE_BASE = import.meta.env.VITE_TINODE_FILE_BASE?.trim();
|
||||||
|
|
||||||
export class ImApiError extends Error {
|
export class ImApiError extends Error {
|
||||||
@@ -119,7 +120,10 @@ export function requestId(): string {
|
|||||||
|
|
||||||
export function apiBase(): string {
|
export function apiBase(): string {
|
||||||
const endpoints = useSessionStore.getState().tenant?.endpoints;
|
const endpoints = useSessionStore.getState().tenant?.endpoints;
|
||||||
return (endpoints?.api || DEFAULT_API_BASE).replace(/\/$/, '');
|
// Production serves the static web app and IM API from different origins. The configured origin
|
||||||
|
// must also work before a tenant has been resolved, while local development keeps using Vite's
|
||||||
|
// same-origin proxy by leaving this value unset.
|
||||||
|
return (CONFIGURED_API_BASE || endpoints?.api || DEFAULT_API_BASE).replace(/\/$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileBase(): string {
|
export function fileBase(): string {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
/** User-visible product name. */
|
/** User-visible product name. */
|
||||||
export const BASE_APP_NAME = '迅工捷使';
|
export const BASE_APP_NAME = '迅工捷使';
|
||||||
|
|
||||||
|
/** This deployment serves a single enterprise; users cannot switch tenants. */
|
||||||
|
export const FIXED_TENANT_CODE = 'default';
|
||||||
|
|
||||||
/** Name used in the Tinode client's User-Agent string. */
|
/** Name used in the Tinode client's User-Agent string. */
|
||||||
export const APP_NAME = `MeetingIM/${__APP_VERSION__}`;
|
export const APP_NAME = `MeetingIM/${__APP_VERSION__}`;
|
||||||
|
|
||||||
@@ -16,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 };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+1
@@ -4,6 +4,7 @@
|
|||||||
declare const __APP_VERSION__: string;
|
declare const __APP_VERSION__: string;
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_TINODE_API_BASE?: string;
|
||||||
readonly VITE_TINODE_API_KEY?: string;
|
readonly VITE_TINODE_API_KEY?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const source = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
test('tenant entry is permanently fixed to default', async () => {
|
||||||
|
const config = await source('src/lib/config.ts');
|
||||||
|
const entry = await source('src/features/auth/TenantEntryPage.tsx');
|
||||||
|
|
||||||
|
assert.match(config, /FIXED_TENANT_CODE = 'default'/);
|
||||||
|
assert.match(entry, /value=\{FIXED_TENANT_CODE\}/);
|
||||||
|
assert.match(entry, /readOnly/);
|
||||||
|
assert.match(entry, /resolveTenant\(FIXED_TENANT_CODE\)/);
|
||||||
|
assert.doesNotMatch(entry, /setCode/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login page exposes no tenant switch and rejects stale tenant state', async () => {
|
||||||
|
const login = await source('src/features/auth/LoginPage.tsx');
|
||||||
|
|
||||||
|
assert.match(login, /tenant\.code !== FIXED_TENANT_CODE/);
|
||||||
|
assert.match(login, /企业码:\{FIXED_TENANT_CODE\}/);
|
||||||
|
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