Skip to content
Merged
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
87 changes: 87 additions & 0 deletions __tests__/opened.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { File } from '@nextcloud/files'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const post = vi.fn()
const isPublicShare = vi.fn(() => false)
const getCurrentUser = vi.fn<() => { uid: string } | null>(() => ({ uid: 'alice' }))
const logger = { debug: vi.fn(), warn: vi.fn() }

vi.mock('@nextcloud/axios', () => ({ default: { post } }))
vi.mock('@nextcloud/router', () => ({ generateOcsUrl: (url: string) => `/ocs/v2.php/${url}` }))
vi.mock('@nextcloud/sharing/public', () => ({ isPublicShare: () => isPublicShare() }))
vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => getCurrentUser() }))
vi.mock('../lib/services/logger.ts', () => ({ logger }))

const { reportOpened } = await import('../lib/services/opened.ts')

const file = new File({
source: 'https://cloud.example.com/remote.php/dav/files/alice/a.jpg',
root: '/files/alice',
owner: 'alice',
mime: 'image/jpeg',
id: 42,
})

describe('reportOpened', () => {
beforeEach(() => {
post.mockReset().mockResolvedValue({})
isPublicShare.mockReturnValue(false)
getCurrentUser.mockReturnValue({ uid: 'alice' })
logger.debug.mockReset()
logger.warn.mockReset()
})

it('reports the file by its id', async () => {
await reportOpened(file)

expect(post).toHaveBeenCalledWith('/ocs/v2.php/apps/files/api/v1/opened', { fileId: 42 })
})

it('reports nothing for a guest', async () => {
getCurrentUser.mockReturnValue(null)

await reportOpened(file)
expect(post).not.toHaveBeenCalled()
})

it('reports nothing on a public share page', async () => {
isPublicShare.mockReturnValue(true)

await reportOpened(file)
expect(post).not.toHaveBeenCalled()
})

// Talk hands a logged-in user files it took from a share
it('reports nothing for a file reached through a public share', async () => {
const shared = new File({
source: 'https://cloud.example.com/public.php/dav/files/abc/a.jpg',
root: '/files/abc',
owner: null,
mime: 'image/jpeg',
id: 42,
})

await reportOpened(shared)
expect(post).not.toHaveBeenCalled()
})

// A server before 36, or a file the user cannot see: nothing to warn about
it('only logs for debugging when the server answers 404', async () => {
post.mockRejectedValue(Object.assign(new Error('Not found'), { response: { status: 404 } }))

await expect(reportOpened(file)).resolves.toBeUndefined()
expect(logger.debug).toHaveBeenCalledOnce()
expect(logger.warn).not.toHaveBeenCalled()
})

it('warns, without failing, when the request fails otherwise', async () => {
post.mockRejectedValue(new Error('offline'))

await expect(reportOpened(file)).resolves.toBeUndefined()
expect(logger.warn).toHaveBeenCalledOnce()
})
})
39 changes: 39 additions & 0 deletions lib/services/opened.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IFile } from '@nextcloud/files'

import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import { isPublicShare } from '@nextcloud/sharing/public'
import { publicShareOf } from '../utils/publicShare.ts'
import { logger } from './logger.ts'

/**
* Tell the server the user opened a file, so apps like recommendations
* know what they work with. Reading a file says nothing about that: sync
* clients and previews read files all the time.
*
* Only a user's own files: a guest or a file reached through a public
* share has no file id the user could open. Opening is never held up by
* it, and a server before 36 has no such endpoint.
*
* @param file - The file the viewer was asked to open
*/
export async function reportOpened(file: IFile): Promise<void> {
if (getCurrentUser() === null || isPublicShare() || publicShareOf(file) || file.fileid === undefined) {
return
}

try {
await axios.post(generateOcsUrl('apps/files/api/v1/opened'), { fileId: file.fileid })
} catch (error) {
if ((error as { response?: { status?: number } }).response?.status === 404) {
logger.debug('The server does not know this file or how to report opening it', { file, error })
return
}
logger.warn('Could not report the file as opened', { file, error })
}
}
3 changes: 3 additions & 0 deletions lib/views/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ import { getHandlers, isHandlerEnabled } from '../handlers.ts'
import { getHandlerForFile } from '../helpers/handlerHelper.ts'
import { fetchFolderContent } from '../services/dav.ts'
import { logger } from '../services/logger.ts'
import { reportOpened } from '../services/opened.ts'
import { dispatchViewerEvent, ViewerSession } from '../session.ts'
import { prepareDownload } from '../utils/beforeDownload.ts'
import { canDownload } from '../utils/canDownload.ts'
Expand Down Expand Up @@ -1356,6 +1357,8 @@ async function showFiles(files: IFile[], file?: IFile, options?: ViewerOptions,
if (!isSameFile) {
loading.value = true
pendingLoads.value = 1
// Only what was asked for: stepping through the others is browsing
reportOpened(file)
}
// A failure to open something else earlier is not this file's problem
errorString.value = null
Expand Down
Loading