Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions core/src/session-heartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,34 @@ function registerAutoLogout() {
return
}

const logoutCheckInterval = 1000
let lastActive = Date.now()
window.addEventListener('mousemove', () => {
let lastStored = 0
const markActive = () => {
lastActive = Date.now()
localStorage.setItem('lastActive', JSON.stringify(lastActive))
})

window.addEventListener('touchstart', () => {
lastActive = Date.now()
localStorage.setItem('lastActive', JSON.stringify(lastActive))
})
if (lastActive - lastStored < logoutCheckInterval) {
return
}
lastStored = lastActive
try {
localStorage.setItem('lastActive', JSON.stringify(lastActive))
} catch (error) {
logger.debug('Could not share the last activity with other tabs', { error })
}
}

// Capture phase, as editors may stop the propagation of input events
window.addEventListener('mousemove', markActive, { capture: true })
// Touch, pen and clicks, including clicks by assistive technology that do not move the mouse
window.addEventListener('pointerdown', markActive, { capture: true })
window.addEventListener('keydown', markActive, { capture: true })
// Text insertion without key events, e.g. dictation
window.addEventListener('beforeinput', markActive, { capture: true })
// Wheel instead of scroll events, as scripts fire scroll events too
window.addEventListener('wheel', markActive, { capture: true, passive: true })
Comment on lines +142 to +150

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am unsure if this is not too much of events, did you do a performance analysis?

If there is a bad performance impact here, we might need to trim this down to

  • mousemove
  • touchstart (mobile)
  • keydown (typing or navigating with keyboard)
  • wheel (scrolling long documents)


// Besides other tabs, apps with cross-origin editors (e.g. richdocuments) dispatch
// this event in the same window to report activity inside their iframes
window.addEventListener('storage', (event) => {
if (event.key !== 'lastActive') {
return
Expand All @@ -154,5 +171,5 @@ function registerAutoLogout() {
window.location.href = logoutUrl
}
}
intervalId = window.setInterval(logoutCheck, 1000)
intervalId = window.setInterval(logoutCheck, logoutCheckInterval)
}
183 changes: 182 additions & 1 deletion core/src/tests/OC/session-heartbeat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,27 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextcloudUser } from '@nextcloud/auth'
import type { MockInstance } from 'vitest'

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

const requestToken = vi.hoisted(() => ({
fetchRequestToken: vi.fn<() => Promise<string>>(),
getRequestToken: vi.fn<() => string>(),
setRequestToken: vi.fn<(token: string) => void>(),
}))
vi.mock('../../OC/requesttoken.ts', () => requestToken)

const initialState = vi.hoisted(() => ({ loadState: vi.fn() }))
vi.mock('@nextcloud/initial-state', () => initialState)

const auth = vi.hoisted(() => ({ getCurrentUser: vi.fn<() => NextcloudUser | null>(() => null) }))
vi.mock('@nextcloud/auth', () => auth)

describe('Session heartbeat', () => {
let addEventListener: MockInstance<typeof window.addEventListener>

beforeAll(() => {
vi.useFakeTimers()
})
Expand All @@ -23,6 +32,15 @@ describe('Session heartbeat', () => {
vi.clearAllTimers()
vi.resetModules()
vi.resetAllMocks()
addEventListener = vi.spyOn(window, 'addEventListener')
})

afterEach(() => {
// Each test imports a new module instance, so its listeners must not outlive the test
for (const listener of addEventListener.mock.calls) {
window.removeEventListener(...listener)
}
vi.restoreAllMocks()
})

it('sends heartbeat half the session lifetime when heartbeat enabled', async () => {
Expand Down Expand Up @@ -120,4 +138,167 @@ describe('Session heartbeat', () => {
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce()
})

describe('auto logout', () => {
const pageUrl = 'http://nextcloud.local/apps/text'
const logoutUrl = '/index.php/logout?requesttoken=csrf-token'
let originalLocation: Location

beforeEach(() => {
originalLocation = window.location
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { href: pageUrl },
})

auth.getCurrentUser.mockReturnValue({ uid: 'user', displayName: 'User', isAdmin: false })
requestToken.getRequestToken.mockReturnValue('csrf-token')
initialState.loadState.mockImplementationOnce(() => ({
auto_logout: true,
session_lifetime: 120,
}))
})

afterEach(() => {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: originalLocation,
})
document.body.replaceChildren()
localStorage.clear()
})

/**
* Dispatch the activity before the session lifetime is over and
* expect the logout to happen one session lifetime after it.
*
* @param dispatchActivity - Callback dispatching the user activity
*/
async function expectActivity(dispatchActivity: () => void) {
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

await vi.advanceTimersByTimeAsync(100 * 1000)
dispatchActivity()

await vi.advanceTimersByTimeAsync(120 * 1000)
expect(window.location.href).toBe(pageUrl)

await vi.advanceTimersByTimeAsync(1000)
expect(window.location.href).toBe(logoutUrl)
}

it('logs out once the session lifetime passed without activity', async () => {
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

await vi.advanceTimersByTimeAsync(120 * 1000)
expect(window.location.href).toBe(pageUrl)

await vi.advanceTimersByTimeAsync(1000)
expect(window.location.href).toBe(logoutUrl)
})

it.each([
{ activity: 'mouse movement', createEvent: () => new MouseEvent('mousemove', { bubbles: true }) },
{ activity: 'touch', createEvent: () => new PointerEvent('pointerdown', { bubbles: true, pointerType: 'touch' }) },
{ activity: 'click without mouse movement', createEvent: () => new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }) },
{ activity: 'key press', createEvent: () => new KeyboardEvent('keydown', { bubbles: true }) },
{ activity: 'text input', createEvent: () => new InputEvent('beforeinput', { bubbles: true, inputType: 'insertText', data: 'a' }) },
{ activity: 'wheel scrolling', createEvent: () => new WheelEvent('wheel', { bubbles: true }) },
])('counts $activity as activity, even when an editor stops its propagation', async ({ createEvent }) => {
const activityEvent = createEvent()
const editor = document.createElement('div')
editor.addEventListener(activityEvent.type, (event) => event.stopPropagation())
document.body.appendChild(editor)

await expectActivity(() => editor.dispatchEvent(activityEvent))
})

it('does not delay scrolling with its wheel listener', async () => {
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

expect(addEventListener).toHaveBeenCalledWith('wheel', expect.any(Function), { capture: true, passive: true })
})

it('counts activity reported by apps through a storage event in the same window', async () => {
await expectActivity(() => window.dispatchEvent(new StorageEvent('storage', {
key: 'lastActive',
newValue: Date.now().toString(),
})))
})

it('does not count scrolling done by scripts as activity', async () => {
const element = document.createElement('div')
document.body.appendChild(element)

const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

await vi.advanceTimersByTimeAsync(100 * 1000)
element.dispatchEvent(new Event('scroll'))

await vi.advanceTimersByTimeAsync(21 * 1000)
expect(window.location.href).toBe(logoutUrl)
})

it('stores the last activity at most once per second', async () => {
const start = Date.now()
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

await vi.advanceTimersByTimeAsync(100 * 1000)
document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
expect(localStorage.getItem('lastActive')).toBe(JSON.stringify(start + 100 * 1000))

await vi.advanceTimersByTimeAsync(999)
document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
expect(localStorage.getItem('lastActive')).toBe(JSON.stringify(start + 100 * 1000))

await vi.advanceTimersByTimeAsync(1)
document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
expect(localStorage.getItem('lastActive')).toBe(JSON.stringify(start + 101 * 1000))
})

it('stores the first activity right after the page load', async () => {
const start = Date.now()
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

await vi.advanceTimersByTimeAsync(500)
document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
expect(localStorage.getItem('lastActive')).toBe(JSON.stringify(start + 500))
})

it('counts activity that is not stored for other tabs yet', async () => {
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

await vi.advanceTimersByTimeAsync(99500)
document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
await vi.advanceTimersByTimeAsync(999)
document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))

// The logout for the stored activity would be due at 220 s, the one for the latest activity at 221 s
await vi.advanceTimersByTimeAsync(220000 - 100499)
expect(window.location.href).toBe(pageUrl)

await vi.advanceTimersByTimeAsync(1000)
expect(window.location.href).toBe(logoutUrl)
})

it('counts activity when the browser storage is not available', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('The quota has been exceeded.', 'QuotaExceededError')
})
const onError = vi.fn()
window.addEventListener('error', onError)

await expectActivity(() => document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })))
expect(onError).not.toHaveBeenCalled()
})
})
})
Loading