diff --git a/src/app/pages/main/main.component.ts b/src/app/pages/main/main.component.ts index e79ba77af..066ad047d 100644 --- a/src/app/pages/main/main.component.ts +++ b/src/app/pages/main/main.component.ts @@ -1,4 +1,4 @@ -import { Component, HostListener, OnInit } from '@angular/core'; +import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; import { DataStore, User } from '@app/globals'; import { HttpService, I18nService, LogService, SettingService, ViewService } from '@app/services'; import { environment } from '@src/environments/environment'; @@ -11,13 +11,31 @@ interface SiteMessagePopup { message: string; } +function createSessionClientId(): string { + if (typeof window.crypto.randomUUID === 'function') { + return window.crypto.randomUUID(); + } + + const bytes = window.crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20) + ].join('-'); +} + @Component({ standalone: false, selector: 'pages-main', templateUrl: 'main.component.html', styleUrls: ['main.component.scss'] }) -export class PageMainComponent implements OnInit { +export class PageMainComponent implements OnInit, OnDestroy { User = User; store = DataStore; showIframeHider = false; @@ -34,6 +52,11 @@ export class PageMainComponent implements OnInit { private popupMessages: SiteMessagePopup[] = []; private popupDialogRef: NzModalRef | null = null; private currentPopupMessage: SiteMessagePopup | null = null; + private readonly userSessionClientId = createSessionClientId(); + private userSessionHeartbeatInterval = 30 * 1000; + private userSessionHeartbeatTimer: number | null = null; + private userSessionHeartbeatInFlight = false; + private userSessionReleased = false; constructor( public viewSrv: ViewService, @@ -56,7 +79,7 @@ export class PageMainComponent implements OnInit { } ngOnInit(): void { - this._http.getUserSession().subscribe(); + this.renewUserSession(); this._settingSvc.isDirectNavigation$.subscribe(state => { this.isDirectNavigation = state; }); @@ -64,6 +87,81 @@ export class PageMainComponent implements OnInit { this.connectWebsocket(); } + ngOnDestroy(): void { + this.stopUserSessionHeartbeat(); + } + + private renewUserSession() { + if (this.userSessionHeartbeatInFlight) { + return; + } + + this.stopUserSessionHeartbeat(); + this.userSessionHeartbeatInFlight = true; + this._http.renewUserSession(this.userSessionClientId).subscribe({ + next: data => { + this.userSessionHeartbeatInFlight = false; + if (data?.ok === false) { + this.userSessionReleased = true; + this.stopUserSessionHeartbeat(); + return; + } + this.userSessionReleased = false; + const interval = Number(data?.heartbeat_interval); + if (interval > 0) { + this.userSessionHeartbeatInterval = interval * 1000; + } + this.scheduleUserSessionHeartbeat(); + }, + error: () => { + this.userSessionHeartbeatInFlight = false; + this.stopUserSessionHeartbeat(); + } + }); + } + + private scheduleUserSessionHeartbeat() { + this.stopUserSessionHeartbeat(); + if (this.userSessionReleased) { + return; + } + this.userSessionHeartbeatTimer = window.setTimeout( + () => this.renewUserSession(), + this.userSessionHeartbeatInterval + ); + } + + private stopUserSessionHeartbeat() { + if (this.userSessionHeartbeatTimer !== null) { + window.clearTimeout(this.userSessionHeartbeatTimer); + this.userSessionHeartbeatTimer = null; + } + } + + @HostListener('document:visibilitychange') + onVisibilityChange() { + if (document.visibilityState === 'visible') { + this.renewUserSession(); + } + } + + @HostListener('window:pageshow') + onPageShow() { + this.userSessionReleased = false; + this.renewUserSession(); + } + + @HostListener('window:pagehide', ['$event']) + onPageHide($event: PageTransitionEvent) { + if ($event.persisted || this.userSessionReleased) { + return; + } + + this.userSessionReleased = true; + this.stopUserSessionHeartbeat(); + this._http.releaseUserSessionOnPageHide(this.userSessionClientId).catch(() => {}); + } + handleLayoutSettingChange(collapsed: boolean) { this.collapsed = collapsed; if (collapsed) { @@ -164,7 +262,6 @@ export class PageMainComponent implements OnInit { @HostListener('window:beforeunload', ['$event']) unloadNotification($event: any) { - this._http.deleteUserSession().subscribe(); if (!environment.production || this.isDirectNavigation) { return; } diff --git a/src/app/services/http.ts b/src/app/services/http.ts index ba504d9f0..90eb3fd1b 100644 --- a/src/app/services/http.ts +++ b/src/app/services/http.ts @@ -157,14 +157,28 @@ export class HttpService { return Object.assign({}, res[0], res[1]); } - getUserSession() { + renewUserSession(clientId: string) { const url = '/api/v1/authentication/user-session/'; - return this.get<_User>(url); + return this.post<_User>(url, { client_id: clientId }); } - deleteUserSession() { + releaseUserSessionOnPageHide(clientId: string) { const url = '/api/v1/authentication/user-session/'; - return this.delete<_User>(url); + const csrfToken = getCsrfTokenFromCookie(); + const headers: Record = { + 'Content-Type': 'application/json' + }; + if (csrfToken) { + headers['X-CSRFToken'] = csrfToken; + } + + return fetch(this.resolveUrl(url), { + method: 'DELETE', + credentials: 'same-origin', + keepalive: true, + headers, + body: JSON.stringify({ client_id: clientId }) + }); } getMyGrantedAssets(keyword) { @@ -381,8 +395,7 @@ export class HttpService { const secret = encryptPassword(manualAuthInfo.secret); const connectOption = { ...(connectData.connectOption || {}) }; // 始终以当前表单为准,避免 connectOption 里残留上一次的 input_secret_type - const inputSecretType = - (manualAuthInfo && manualAuthInfo['input_secret_type']) || 'password'; + const inputSecretType = (manualAuthInfo && manualAuthInfo['input_secret_type']) || 'password'; const data = { asset: asset.id, @@ -518,7 +531,10 @@ export class HttpService { } getSmartEndpoint({ assetId, sessionId, token }, protocol): Promise { - const url = new URL(withSitePrefix('/api/v1/terminal/endpoints/smart/'), window.location.origin); + const url = new URL( + withSitePrefix('/api/v1/terminal/endpoints/smart/'), + window.location.origin + ); url.searchParams.append('protocol', protocol); if (assetId) {