From da5828ef423f5bc30bacff7efda28b142be9eeaf Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 4 Jun 2025 18:25:13 +0200 Subject: [PATCH] refactor(OC): restructure session heartbeat code - use types and human reading order Signed-off-by: Ferdinand Thiessen --- core/src/session-heartbeat.ts | 233 ++++++++++++++++------------------ 1 file changed, 111 insertions(+), 122 deletions(-) diff --git a/core/src/session-heartbeat.ts b/core/src/session-heartbeat.ts index 5ae2e8192a5..599fa161760 100644 --- a/core/src/session-heartbeat.ts +++ b/core/src/session-heartbeat.ts @@ -3,152 +3,55 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import $ from 'jquery' import { emit } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' import { getCurrentUser } from '@nextcloud/auth' import { generateUrl } from '@nextcloud/router' +import { + fetchRequestToken, + getRequestToken, +} from './OC/requesttoken.ts' +import logger from './logger.js' -import OC from './OC/index.js' -import { setToken as setRequestToken, getToken as getRequestToken } from './OC/requesttoken.ts' - -let config = null -/** - * The legacy jsunit tests overwrite OC.config before calling initCore - * therefore we need to wait with assigning the config fallback until initCore calls initSessionHeartBeat - */ -const loadConfig = () => { - try { - config = loadState('core', 'config') - } catch (e) { - // This fallback is just for our legacy jsunit tests since we have no way to mock loadState calls - config = OC.config - } +interface OcJsConfig { + auto_logout: boolean + session_keepalive: boolean + session_lifetime: number } -/** - * session heartbeat (defaults to enabled) - * - * @return {boolean} - */ -const keepSessionAlive = () => { - return config.session_keepalive === undefined - || !!config.session_keepalive -} - -/** - * get interval in seconds - * - * @return {number} - */ -const getInterval = () => { - let interval = NaN - if (config.session_lifetime) { - interval = Math.floor(config.session_lifetime / 2) - } - - // minimum one minute, max 24 hours, default 15 minutes - return Math.min( - 24 * 3600, - Math.max( - 60, - isNaN(interval) ? 900 : interval, - ), - ) -} - -const getToken = async () => { - const url = generateUrl('/csrftoken') - - // Not using Axios here as Axios is not stubbable with the sinon fake server - // see https://stackoverflow.com/questions/41516044/sinon-mocha-test-with-async-ajax-calls-didnt-return-promises - // see js/tests/specs/coreSpec.js for the tests - const resp = await $.get(url) - - return resp.token -} - -const poll = async () => { - try { - const token = await getToken() - setRequestToken(token) - } catch (e) { - console.error('session heartbeat failed', e) - } -} - -const startPolling = () => { - const interval = setInterval(poll, getInterval() * 1000) - - console.info('session heartbeat polling started') - - return interval -} - -const registerAutoLogout = () => { - if (!config.auto_logout || !getCurrentUser()) { - return - } - - let lastActive = Date.now() - window.addEventListener('mousemove', e => { - lastActive = Date.now() - localStorage.setItem('lastActive', lastActive) - }) - - window.addEventListener('touchstart', e => { - lastActive = Date.now() - localStorage.setItem('lastActive', lastActive) - }) - - window.addEventListener('storage', e => { - if (e.key !== 'lastActive') { - return - } - lastActive = e.newValue - }) - - let intervalId = 0 - const logoutCheck = () => { - const timeout = Date.now() - config.session_lifetime * 1000 - if (lastActive < timeout) { - clearTimeout(intervalId) - console.info('Inactivity timout reached, logging out') - const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken()) - window.location = logoutUrl - } - } - intervalId = setInterval(logoutCheck, 1000) -} +const { + auto_logout: autoLogout, + session_keepalive: keepSessionAlive, + session_lifetime: seesionLifetime, +} = loadState('core', 'config') /** * Calls the server periodically to ensure that session and CSRF * token doesn't expire */ -export const initSessionHeartBeat = () => { - loadConfig() - +export function initSessionHeartBeat() { registerAutoLogout() - if (!keepSessionAlive()) { - console.info('session heartbeat disabled') + if (!keepSessionAlive) { + logger.info('Session heartbeat disabled') return } - let interval = startPolling() + let interval = startPolling() window.addEventListener('online', async () => { - console.info('browser is online again, resuming heartbeat') + logger.info('Browser is online again, resuming heartbeat') + interval = startPolling() try { await poll() - console.info('session token successfully updated after resuming network') + logger.info('Session token successfully updated after resuming network') // Let apps know we're online and requests will have the new token emit('networkOnline', { success: true, }) - } catch (e) { - console.error('could not update session token after resuming network', e) + } catch (error) { + logger.error('could not update session token after resuming network', { error }) // Let apps know we're online but requests might have an outdated token emit('networkOnline', { @@ -156,13 +59,99 @@ export const initSessionHeartBeat = () => { }) } }) + window.addEventListener('offline', () => { - console.info('browser is offline, stopping heartbeat') + logger.info('Browser is offline, stopping heartbeat') // Let apps know we're offline emit('networkOffline', {}) clearInterval(interval) - console.info('session heartbeat polling stopped') + logger.info('Session heartbeat polling stopped') }) } + +/** + * Get interval in seconds + */ +function getInterval(): number { + const interval = seesionLifetime + ? Math.floor(seesionLifetime / 2) + : 900 + + // minimum one minute, max 24 hours, default 15 minutes + return Math.min( + 24 * 3600, + Math.max( + 60, + interval, + ), + ) +} + +/** + * Poll the CSRF token for changes. + * This will also extend the current session if needed. + */ +async function poll() { + try { + await fetchRequestToken() + } catch (error) { + logger.error('session heartbeat failed', { error }) + } +} + +/** + * Start an window interval with the polling as the callback. + * + * @return The interval id + */ +function startPolling(): number { + const interval = window.setInterval(poll, getInterval() * 1000) + + logger.info('session heartbeat polling started') + return interval +} + +/** + * If enabled this will register event listeners to track if a user is active. + * If not the user will be automatically logged out after the configured IDLE time. + */ +function registerAutoLogout() { + if (!autoLogout || !getCurrentUser()) { + return + } + + let lastActive = Date.now() + window.addEventListener('mousemove', () => { + lastActive = Date.now() + localStorage.setItem('lastActive', JSON.stringify(lastActive)) + }) + + window.addEventListener('touchstart', () => { + lastActive = Date.now() + localStorage.setItem('lastActive', JSON.stringify(lastActive)) + }) + + window.addEventListener('storage', (event) => { + if (event.key !== 'lastActive') { + return + } + if (event.newValue === null) { + return + } + lastActive = JSON.parse(event.newValue) + }) + + let intervalId = 0 + const logoutCheck = () => { + const timeout = Date.now() - seesionLifetime * 1000 + if (lastActive < timeout) { + clearTimeout(intervalId) + logger.info('Inactivity timout reached, logging out') + const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken()) + window.location.href = logoutUrl + } + } + intervalId = window.setInterval(logoutCheck, 1000) +}