From b710590a1f5f9b115c721026db2509b34f4aa47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:01:11 +0100 Subject: [PATCH 1/2] feat: report the files the viewer opens to the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit So apps like recommendations know what the user works with. Only the file the viewer was asked to open counts, not the ones the user steps through, and nothing is reported for guests or public shares. Servers before 36 have no such endpoint: their 404 is only logged for debugging. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- __tests__/opened.spec.ts | 90 ++++++++++++++++++++++++++++++++++++++++ lib/services/opened.ts | 39 +++++++++++++++++ lib/views/Viewer.vue | 3 ++ 3 files changed, 132 insertions(+) create mode 100644 __tests__/opened.spec.ts create mode 100644 lib/services/opened.ts diff --git a/__tests__/opened.spec.ts b/__tests__/opened.spec.ts new file mode 100644 index 0000000..7c90cf5 --- /dev/null +++ b/__tests__/opened.spec.ts @@ -0,0 +1,90 @@ +/*! + * 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 }, + isAxiosError: (error: { isAxiosError?: boolean }) => error.isAxiosError === true, +})) +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'), { isAxiosError: true, 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() + }) +}) diff --git a/lib/services/opened.ts b/lib/services/opened.ts new file mode 100644 index 0000000..e46ed39 --- /dev/null +++ b/lib/services/opened.ts @@ -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, { isAxiosError } 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 { + 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 (isAxiosError(error) && error.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 }) + } +} diff --git a/lib/views/Viewer.vue b/lib/views/Viewer.vue index 256f5c3..aab0bb3 100644 --- a/lib/views/Viewer.vue +++ b/lib/views/Viewer.vue @@ -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' @@ -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 From b7b87cd88a23d62749ec8d1fb1ba30ac8200dd78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:13:38 +0100 Subject: [PATCH 2/2] fix: do not rely on isAxiosError to spot a 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests that mock @nextcloud/axios without it made the report throw from its catch, an unhandled rejection in every test opening a file. Check the status the way the rest of the viewer does. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- __tests__/opened.spec.ts | 7 ++----- lib/services/opened.ts | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/__tests__/opened.spec.ts b/__tests__/opened.spec.ts index 7c90cf5..bdbb364 100644 --- a/__tests__/opened.spec.ts +++ b/__tests__/opened.spec.ts @@ -10,10 +10,7 @@ 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 }, - isAxiosError: (error: { isAxiosError?: boolean }) => error.isAxiosError === true, -})) +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() })) @@ -74,7 +71,7 @@ describe('reportOpened', () => { // 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'), { isAxiosError: true, response: { status: 404 } })) + post.mockRejectedValue(Object.assign(new Error('Not found'), { response: { status: 404 } })) await expect(reportOpened(file)).resolves.toBeUndefined() expect(logger.debug).toHaveBeenCalledOnce() diff --git a/lib/services/opened.ts b/lib/services/opened.ts index e46ed39..72dcaa0 100644 --- a/lib/services/opened.ts +++ b/lib/services/opened.ts @@ -5,7 +5,7 @@ import type { IFile } from '@nextcloud/files' import { getCurrentUser } from '@nextcloud/auth' -import axios, { isAxiosError } from '@nextcloud/axios' +import axios from '@nextcloud/axios' import { generateOcsUrl } from '@nextcloud/router' import { isPublicShare } from '@nextcloud/sharing/public' import { publicShareOf } from '../utils/publicShare.ts' @@ -30,7 +30,7 @@ export async function reportOpened(file: IFile): Promise { try { await axios.post(generateOcsUrl('apps/files/api/v1/opened'), { fileId: file.fileid }) } catch (error) { - if (isAxiosError(error) && error.response?.status === 404) { + 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 }