From 011ad5d6cca400859baf2698876ebfd9e9cdbbeb Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Wed, 1 Apr 2026 22:28:06 +0200 Subject: [PATCH] Zotero Integration (#164) --- develop/docker-compose.yml | 2 +- docker-compose.yml | 2 +- server-ce/test/docker-compose.yml | 2 +- .../LinkedFiles/LinkedFilesController.mjs | 11 +- services/web/config/settings.defaults.js | 43 +++- .../web/frontend/extracted-translations.json | 3 + services/web/locales/de.json | 3 + services/web/locales/en.json | 3 + services/web/locales/ru.json | 11 +- .../app/src/AccessTokenEncryptorHelper.mjs | 66 ++++++ .../zotero/app/src/ZoteroApiClient.mjs | 208 ++++++++++++++++++ .../zotero/app/src/ZoteroController.mjs | 86 ++++++++ .../zotero/app/src/ZoteroLinkedFileAgent.mjs | 131 +++++++++++ .../modules/zotero/app/src/ZoteroRouter.mjs | 27 +++ .../js/components/tpr-file-view-info.tsx | 45 ++++ .../tpr-file-view-not-original-importer.tsx | 55 +++++ .../tpr-file-view-refresh-button.tsx | 56 +++++ .../tpr-file-view-refresh-error.tsx | 46 ++++ .../js/components/zotero-create-file.tsx | 144 ++++++++++++ .../frontend/js/components/zotero-widget.tsx | 135 ++++++++++++ services/web/modules/zotero/index.mjs | 19 ++ .../LinkedFilesController.test.mjs | 32 +++ 22 files changed, 1117 insertions(+), 13 deletions(-) create mode 100644 services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs create mode 100644 services/web/modules/zotero/app/src/ZoteroApiClient.mjs create mode 100644 services/web/modules/zotero/app/src/ZoteroController.mjs create mode 100644 services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs create mode 100644 services/web/modules/zotero/app/src/ZoteroRouter.mjs create mode 100644 services/web/modules/zotero/frontend/js/components/tpr-file-view-info.tsx create mode 100644 services/web/modules/zotero/frontend/js/components/tpr-file-view-not-original-importer.tsx create mode 100644 services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-button.tsx create mode 100644 services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-error.tsx create mode 100644 services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx create mode 100644 services/web/modules/zotero/frontend/js/components/zotero-widget.tsx create mode 100644 services/web/modules/zotero/index.mjs diff --git a/develop/docker-compose.yml b/develop/docker-compose.yml index b979d1f8e5..be6917aef8 100644 --- a/develop/docker-compose.yml +++ b/develop/docker-compose.yml @@ -162,7 +162,7 @@ services: - dev.env environment: - APP_NAME=Overleaf Community Edition - - ENABLED_LINKED_FILE_TYPES=project_file,project_output_file + - ENABLED_LINKED_FILE_TYPES=project_file,project_output_file,zotero - EMAIL_CONFIRMATION_DISABLED=true - NODE_ENV=development - OVERLEAF_ALLOW_PUBLIC_ACCESS=true diff --git a/docker-compose.yml b/docker-compose.yml index fc2c01fa71..5f32313627 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,7 @@ services: OVERLEAF_REDIS_HOST: redis REDIS_HOST: redis - ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file" + ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file,zotero" # Enables Thumbnail generation using ImageMagick ENABLE_CONVERSIONS: "true" diff --git a/server-ce/test/docker-compose.yml b/server-ce/test/docker-compose.yml index 6d26e31f00..58b3928663 100644 --- a/server-ce/test/docker-compose.yml +++ b/server-ce/test/docker-compose.yml @@ -19,7 +19,7 @@ services: OVERLEAF_EMAIL_SMTP_HOST: "mailtrap" OVERLEAF_EMAIL_SMTP_PORT: "25" OVERLEAF_EMAIL_SMTP_IGNORE_TLS: "true" - ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file" + ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file,zotero" ENABLE_CONVERSIONS: "true" EMAIL_CONFIRMATION_DISABLED: "true" healthcheck: diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs index 8a0a465a9d..9f3d4cf684 100644 --- a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs +++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs @@ -189,7 +189,13 @@ export default LinkedFilesController = { refreshLinkedFile: expressify(refreshLinkedFile), handleError(error, req, res, next) { - if (error instanceof AccessDeniedError) { + if ( + error instanceof AccessDeniedError && + error.message === 'Zotero account not linked' + ) { + res.status(400) + plainTextResponse(res, 'Your account is not linked with Zotero') + } else if (error instanceof AccessDeniedError) { res.status(403) plainTextResponse( res, @@ -231,8 +237,7 @@ export default LinkedFilesController = { } else { plainTextResponse( res, - `Your URL could not be reached (${ - error.info?.status || error.cause?.info?.status + `Your URL could not be reached (${error.info?.status || error.cause?.info?.status } status code). Please check it and try again.` ) } diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 2dd6ac3f21..ff813a7051 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1006,14 +1006,39 @@ module.exports = { // // Restart webpack after making changes. // - createFileModes: [], + createFileModes: [ + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/zotero-create-file' + ), + ], devToolbar: [], gitBridge: [], publishModal: [], - tprFileViewInfo: [], - tprFileViewRefreshError: [], - tprFileViewRefreshButton: [], - tprFileViewNotOriginalImporter: [], + tprFileViewInfo: [ + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/tpr-file-view-info' + ), + ], + tprFileViewRefreshError: [ + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/tpr-file-view-refresh-error' + ), + ], + tprFileViewRefreshButton: [ + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/tpr-file-view-refresh-button' + ), + ], + tprFileViewNotOriginalImporter: [ + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/tpr-file-view-not-original-importer' + ), + ], contactUsModal: [], sourceEditorExtensions: [], sourceEditorComponents: [], @@ -1037,7 +1062,12 @@ module.exports = { langFeedbackLinkingWidgets: [], labsExperiments: [], integrationLinkingWidgets: [], - referenceLinkingWidgets: [], + referenceLinkingWidgets: [ + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/zotero-widget' + ), + ], importProjectFromGithubModalWrapper: [], importProjectFromGithubMenu: [], editorLeftMenuSync: [ @@ -1127,6 +1157,7 @@ module.exports = { 'admin-tools', // import after authentication 'template-gallery', 'git-bridge', + 'zotero', ], viewIncludes: {}, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 8cda295760..75e3baff9f 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -2581,9 +2581,12 @@ "zoom_out": "", "zoom_to": "", "zotero": "", + "zotero_account_linked_successfully": "", "zotero_dynamic_sync_description": "", "zotero_groups_loading_error": "", "zotero_groups_relink": "", + "zotero_imported_by_collaborator": "", + "zotero_imported_by_unknown": "", "zotero_integration": "", "zotero_is_premium": "", "zotero_reference_loading_error": "", diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 1e5a9d01d5..6918db64df 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -1741,8 +1741,11 @@ "zh-CN": "Chinesisch", "zip_contents_too_large": "ZIP-Inhalt zu groß", "zotero": "Zotero", + "zotero_account_linked_successfully": "Zotero-Konto erfolgreich verknüpft.", "zotero_groups_loading_error": "Beim Laden von Gruppen von Zotero ist ein Fehler aufgetreten", "zotero_groups_relink": "Beim Zugriff auf die Zotero-Daten ist ein Fehler aufgetreten. Dies wurde wahrscheinlich durch fehlende Berechtigungen verursacht. Bitte verknüpfe dein Konto neu und versuche es erneut.", + "zotero_imported_by_collaborator": "Diese Datei wurde aus Zotero von deinem Mitautor importiert. Du kannst sie nicht aktualisieren.", + "zotero_imported_by_unknown": "Diese Datei wurde aus Zotero von einem unbekannten Benutzer importiert. Du kannst sie nicht aktualisieren.", "zotero_integration": "Zotero-Integration", "zotero_is_premium": "Zotero-Integration ist eine Premiumfunktion", "zotero_reference_loading_error": "Fehler, Referenzen konnten nicht von Mendeley geladen werden", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 61ca041b13..48c07a571e 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -3252,9 +3252,12 @@ "zoom_to": "Zoom to", "zotero": "Zotero", "zotero_and_mendeley_integrations": "<0>Zotero and <0>Mendeley integrations", + "zotero_account_linked_successfully": "Zotero account linked successfully", "zotero_dynamic_sync_description": "With the Zotero integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Zotero library directly from __appName__.", "zotero_groups_loading_error": "There was an error loading groups from Zotero", "zotero_groups_relink": "There was an error accessing your Zotero data. This was likely caused by lack of permissions. Please re-link your account and try again.", + "zotero_imported_by_collaborator": "This file was imported from Zotero by your collaborator. You cannot refresh it.", + "zotero_imported_by_unknown": "This file was imported from Zotero by an unknown user. You cannot refresh it.", "zotero_integration": "Zotero integration", "zotero_is_premium": "Zotero integration is a premium feature", "zotero_reference_loading_error": "Error, could not load references from Zotero", diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index dc7787a191..d40e074c4d 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -223,6 +223,7 @@ "ill_take_it": "Беру!", "import_from_github": "Импорт с GitHub", "import_to_sharelatex": "Импортировать в __appName__", + "imported_from_zotero_at_date": "Импортировано из Zotero в __formattedDate__ __relativeDate__", "importing": "Импорт", "importing_and_merging_changes_in_github": "Импорт и слияние изменений в GitHub", "inactive_projects": "Неактивные проекты", @@ -571,5 +572,13 @@ "your_sessions": "Ваши сессии", "your_subscription_has_expired": "Срок Вашей подписки истёк.", "your_subscriptions": "Ваши подписки", - "zh-CN": "Китайский" + "zh-CN": "Китайский", + "zotero_account_linked_successfully": "Ваш аккаунт Zotero успешно подключён.", + "zotero_groups_loading_error": "При загрузке групп из Zotero произошла ошибка", + "zotero_imported_by_collaborator": "Этот файл был импортирован из Zotero вашим соавтором. Вы не можете обновить файл.", + "zotero_imported_by_unknown": "Этот файл был импортирован из Zotero неизвестным пользователем. Вы не можете обновить файл.", + "zotero_reference_loading_error": "Ошибка: не удалось загрузить ссылки из Zotero", + "zotero_reference_loading_error_expired": "Срок действия токена Zotero истёк, пожалуйста, повторно подключите аккаунт", + "zotero_reference_loading_error_forbidden": "Не удалось загрузить ссылки из Zotero, пожалуйста, повторно подключите аккаунт и попробуйте снова", + "zotero_sync_description": "Используя интеграцию с Zotero вы можете импортировать свои ссылки из Zotero в проекты __appName__." } diff --git a/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs b/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs new file mode 100644 index 0000000000..a12dc296b0 --- /dev/null +++ b/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs @@ -0,0 +1,66 @@ +import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor' +import Settings from '@overleaf/settings' +import fs from 'node:fs' +import crypto from 'node:crypto' +import Path from 'node:path' + +const CIPHER_KEY_FILE = '/var/lib/overleaf/data/.zotero-cipher-key' +const CIPHER_LABEL = '2024.1-v3' + +let encryptorInstance = null + +/** + * Get or create a stable cipher password that persists across container + * recreations. Priority: + * 1. ZOTERO_CIPHER_PASSWORD env var (explicit user config) + * 2. Key file in the persistent volume (/var/lib/overleaf/data/) + * — auto-generated on first use, survives container rebuilds + */ +function _getStableCipherPassword() { + if (process.env.ZOTERO_CIPHER_PASSWORD) { + return process.env.ZOTERO_CIPHER_PASSWORD + } + try { + const existing = fs.readFileSync(CIPHER_KEY_FILE, 'utf8').trim() + if (existing.length >= 16) { + return existing + } + } catch { + // File doesn't exist yet — generate one + } + const newKey = crypto.randomBytes(32).toString('base64') + const dir = Path.dirname(CIPHER_KEY_FILE) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(CIPHER_KEY_FILE, newKey, { mode: 0o600 }) + return newKey +} + +function _getEncryptor() { + if (!encryptorInstance) { + const encryptorSettings = Settings.zotero?.encryptor || Settings.oauthProviders?.encryptor + if (!encryptorSettings) { + const cipherLabel = process.env.ZOTERO_CIPHER_LABEL || CIPHER_LABEL + const cipherPassword = _getStableCipherPassword() + encryptorInstance = new AccessTokenEncryptorClass({ + cipherLabel, + cipherPasswords: { + [cipherLabel]: cipherPassword, + }, + }) + } else { + encryptorInstance = new AccessTokenEncryptorClass(encryptorSettings) + } + } + return encryptorInstance +} + +export const AccessTokenEncryptor = { + promises: { + async encryptJson(json) { + return await _getEncryptor().promises.encryptJson(json) + }, + async decryptToJson(encryptedJson) { + return await _getEncryptor().promises.decryptToJson(encryptedJson) + }, + }, +} diff --git a/services/web/modules/zotero/app/src/ZoteroApiClient.mjs b/services/web/modules/zotero/app/src/ZoteroApiClient.mjs new file mode 100644 index 0000000000..863951904d --- /dev/null +++ b/services/web/modules/zotero/app/src/ZoteroApiClient.mjs @@ -0,0 +1,208 @@ +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import OError from '@overleaf/o-error' +import { fetchJson, fetchString } from '@overleaf/fetch-utils' +import { User } from '../../../../app/src/models/User.mjs' +import { AccessTokenEncryptor } from './AccessTokenEncryptorHelper.mjs' + +const ZOTERO_API_URL = 'https://api.zotero.org' + +/** + * Decrypt stored Zotero credentials from user record. + * Returns { apiKey, zoteroUserId } or null if not linked. + */ +async function _getCredentials(userId) { + const user = await User.findById(userId, 'refProviders.zotero').exec() + if (!user?.refProviders?.zotero?.apiKeyEncrypted) { + return null + } + try { + const decrypted = await AccessTokenEncryptor.promises.decryptToJson( + user.refProviders.zotero.apiKeyEncrypted + ) + return decrypted + } catch (err) { + throw OError.tag(err, 'failed to decrypt Zotero credentials', { userId }) + } +} + +/** + * Make a request to the Zotero API with the user's API key. + */ +async function _zoteroApiRequest(apiKey, path, opts = {}) { + const url = `${ZOTERO_API_URL}${path}` + const headers = { + 'Zotero-API-Version': '3', + 'Zotero-API-Key': apiKey, + ...(opts.headers || {}), + } + return { url, headers } +} + +/** + * Get the list of groups for a user. + */ +async function getGroupsForUser(userId) { + const credentials = await _getCredentials(userId) + if (!credentials) { + throw new ZoteroAccountNotLinkedError() + } + const { apiKey, zoteroUserId } = credentials + const { url, headers } = await _zoteroApiRequest( + apiKey, + `/users/${zoteroUserId}/groups` + ) + try { + const groups = await fetchJson(url, { headers }) + return groups.map(g => ({ + id: String(g.id), + name: g.data?.name || `Group ${g.id}`, + })) + } catch (err) { + logger.err({ err, userId }, 'error fetching Zotero groups') + if (err.response?.status === 403) { + throw new ZoteroForbiddenError('forbidden') + } + throw OError.tag(err, 'error fetching Zotero groups') + } +} + +/** + * Export the user's entire library as BibTeX. + */ +async function getUserLibraryBibtex(userId) { + const credentials = await _getCredentials(userId) + if (!credentials) { + throw new ZoteroAccountNotLinkedError() + } + return _fetchBibtex( + credentials.apiKey, + `/users/${credentials.zoteroUserId}/items` + ) +} + +/** + * Export a group library as BibTeX. + */ +async function getGroupLibraryBibtex(userId, groupId) { + const credentials = await _getCredentials(userId) + if (!credentials) { + throw new ZoteroAccountNotLinkedError() + } + return _fetchBibtex(credentials.apiKey, `/groups/${groupId}/items`) +} + +/** + * Fetch all items from a Zotero library endpoint as BibTeX. + * Handles pagination (Zotero API limits to 100 items per request). + */ +async function _fetchBibtex(apiKey, basePath) { + let allBibtex = '' + let start = 0 + const limit = 100 + + while (true) { + const { url, headers } = await _zoteroApiRequest(apiKey, basePath, { + headers: {}, + }) + const fullUrl = `${url}?format=bibtex&limit=${limit}&start=${start}` + try { + const response = await fetch(fullUrl, { headers }) + if (!response.ok) { + if (response.status === 403) { + throw new ZoteroForbiddenError('Zotero API returned 403') + } + throw new Error(`Zotero API returned ${response.status}`) + } + const bibtex = await response.text() + if (bibtex.trim()) { + allBibtex += bibtex + '\n' + } + const totalResults = parseInt( + response.headers.get('Total-Results') || '0', + 10 + ) + start += limit + if (start >= totalResults) { + break + } + } catch (err) { + if (err instanceof ZoteroForbiddenError) { + throw err + } + throw OError.tag(err, 'error fetching BibTeX from Zotero', { basePath }) + } + } + return allBibtex +} + +/** + * Validate a Zotero API key by calling /keys/{key}. + * Returns { zoteroUserId } on success, throws on failure. + */ +async function validateApiKey(apiKey) { + const url = `${ZOTERO_API_URL}/keys/${encodeURIComponent(apiKey)}` + try { + const data = await fetchJson(url, { + headers: { 'Zotero-API-Version': '3' }, + }) + if (!data.userID) { + throw new Error('Zotero API key response missing userID') + } + return { zoteroUserId: String(data.userID) } + } catch (err) { + if (err.response?.status === 404 || err.response?.status === 403) { + throw new ZoteroForbiddenError('Invalid Zotero API key') + } + throw OError.tag(err, 'error validating Zotero API key') + } +} + +/** + * Link a Zotero account (store encrypted credentials). + */ +async function storeCredentials(userId, apiKey, zoteroUserId) { + const apiKeyEncrypted = await AccessTokenEncryptor.promises.encryptJson({ + apiKey, + zoteroUserId: String(zoteroUserId), + }) + await User.updateOne( + { _id: userId }, + { $set: { 'refProviders.zotero': { apiKeyEncrypted } } } + ).exec() +} + +/** + * Unlink a Zotero account. + */ +async function unlinkAccount(userId) { + await User.updateOne( + { _id: userId }, + { $unset: { 'refProviders.zotero': 1 } } + ).exec() +} + +export class ZoteroForbiddenError extends Error { + constructor(message) { + super(message) + this.name = 'ZoteroForbiddenError' + } +} + +export class ZoteroAccountNotLinkedError extends Error { + constructor(message = 'Zotero account not linked') { + super(message) + this.name = 'ZoteroAccountNotLinkedError' + } +} + +export default { + getGroupsForUser, + getUserLibraryBibtex, + getGroupLibraryBibtex, + validateApiKey, + storeCredentials, + unlinkAccount, + ZoteroForbiddenError, + ZoteroAccountNotLinkedError, +} diff --git a/services/web/modules/zotero/app/src/ZoteroController.mjs b/services/web/modules/zotero/app/src/ZoteroController.mjs new file mode 100644 index 0000000000..c2d834e6ea --- /dev/null +++ b/services/web/modules/zotero/app/src/ZoteroController.mjs @@ -0,0 +1,86 @@ +import logger from '@overleaf/logger' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' +import ZoteroApiClient from './ZoteroApiClient.mjs' +import { ZoteroForbiddenError } from './ZoteroApiClient.mjs' + +/** + * GET /zotero/groups + * Returns the user's Zotero groups (for the create-file modal). + */ +async function getGroups(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + try { + const groups = await ZoteroApiClient.getGroupsForUser(userId) + res.json({ groups }) + } catch (err) { + if (err instanceof ZoteroForbiddenError) { + return res.status(403).json({ + error: 'forbidden', + message: 'zotero_groups_relink', + }) + } + logger.err({ err, userId }, 'error fetching Zotero groups') + res.status(500).json({ + error: 'internal', + message: 'zotero_groups_loading_error', + }) + } +} + +/** + * POST /zotero/link + * Links a Zotero account by validating the user-provided API key + * and storing the encrypted credentials. + * + * Users create their API key at https://www.zotero.org/settings/keys + * with "Allow library access" and "Allow read access to all groups". + */ +async function link(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + const { apiKey } = req.body + + if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) { + return res.status(400).json({ error: 'missing_api_key' }) + } + + try { + const { zoteroUserId } = await ZoteroApiClient.validateApiKey( + apiKey.trim() + ) + await ZoteroApiClient.storeCredentials(userId, apiKey.trim(), zoteroUserId) + res.json({ success: true }) + } catch (err) { + if (err instanceof ZoteroForbiddenError) { + return res.status(400).json({ + error: 'invalid_api_key', + message: 'zotero_api_key_invalid', + }) + } + logger.err({ err, userId }, 'error linking Zotero account') + res.status(500).json({ + error: 'internal', + message: 'generic_something_went_wrong', + }) + } +} + +/** + * POST /zotero/unlink + * Unlinks the user's Zotero account. + */ +async function unlink(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + try { + await ZoteroApiClient.unlinkAccount(userId) + res.sendStatus(200) + } catch (err) { + logger.err({ err, userId }, 'error unlinking Zotero') + res.sendStatus(500) + } +} + +export default { + getGroups, + link, + unlink, +} diff --git a/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs b/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs new file mode 100644 index 0000000000..cf5e0364a6 --- /dev/null +++ b/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs @@ -0,0 +1,131 @@ +import logger from '@overleaf/logger' +import { callbackify } from '@overleaf/promise-utils' +import LinkedFilesHandler from '../../../../app/src/Features/LinkedFiles/LinkedFilesHandler.mjs' +import ZoteroApiClient from './ZoteroApiClient.mjs' +import { ZoteroForbiddenError, ZoteroAccountNotLinkedError } from './ZoteroApiClient.mjs' +import LinkedFilesErrors from '../../../../app/src/Features/LinkedFiles/LinkedFilesErrors.mjs' +import { Project } from '../../../../app/src/models/Project.mjs' + +const { + FeatureNotAvailableError, + AccessDeniedError, + RemoteServiceError, + NotOriginalImporterError, +} = LinkedFilesErrors + +/** + * Create a linked .bib file from Zotero (either My Library or a Group Library). + * + * linkedFileData shape: + * { provider: 'zotero', zoteroGroupId?: string, importedAt: string } + * + * - If zoteroGroupId is present, export that group's library. + * - Otherwise, export the user's personal library ("My Library"). + */ +async function createLinkedFile( + projectId, + linkedFileData, + name, + parentFolderId, + userId +) { + logger.info( + { projectId, userId, groupId: linkedFileData.zoteroGroupId }, + 'creating Zotero linked file' + ) + + linkedFileData.importedByUserId = userId + + const bibtex = await _getBibtex(userId, linkedFileData) + + const file = await LinkedFilesHandler.promises.importContent( + projectId, + bibtex, + _sanitizeData(linkedFileData), + name, + parentFolderId, + userId + ) + return file._id +} + +/** + * Refresh an existing Zotero linked .bib file. + */ +async function refreshLinkedFile( + projectId, + linkedFileData, + name, + parentFolderId, + userId +) { + logger.info( + { projectId, userId, groupId: linkedFileData.zoteroGroupId }, + 'refreshing Zotero linked file' + ) + + if (linkedFileData.importedByUserId) { + if (linkedFileData.importedByUserId !== userId) { + throw new NotOriginalImporterError( + 'Only the user who created the Zotero-linked file can refresh this file' + ) + } + } else { + // No owner metadata (legacy files). Only project owner may refresh. + const project = await Project.findById(projectId, 'owner_ref').lean() + if (!project || String(project.owner_ref) !== String(userId)) { + throw new NotOriginalImporterError( + 'Only the user who created the Zotero-linked file can refresh this file' + ) + } + linkedFileData.importedByUserId = userId + } + + const bibtex = await _getBibtex(userId, linkedFileData) + + const file = await LinkedFilesHandler.promises.importContent( + projectId, + bibtex, + _sanitizeData(linkedFileData), + name, + parentFolderId, + userId + ) + return file._id +} + +async function _getBibtex(userId, linkedFileData) { + try { + if (linkedFileData.zoteroGroupId) { + return await ZoteroApiClient.getGroupLibraryBibtex( + userId, + linkedFileData.zoteroGroupId + ) + } else { + return await ZoteroApiClient.getUserLibraryBibtex(userId) + } + } catch (err) { + if (err instanceof ZoteroForbiddenError) { + throw new AccessDeniedError('Zotero access denied').withCause(err) + } + if (err instanceof ZoteroAccountNotLinkedError) { + throw new AccessDeniedError('Zotero account not linked').withCause(err) + } + throw new RemoteServiceError('Zotero API error').withCause(err) + } +} + +function _sanitizeData(data) { + return { + provider: 'zotero', + zoteroGroupId: data.zoteroGroupId || undefined, + importedAt: data.importedAt, + importedByUserId: data.importedByUserId || undefined, + } +} + +export default { + createLinkedFile: callbackify(createLinkedFile), + refreshLinkedFile: callbackify(refreshLinkedFile), + promises: { createLinkedFile, refreshLinkedFile }, +} diff --git a/services/web/modules/zotero/app/src/ZoteroRouter.mjs b/services/web/modules/zotero/app/src/ZoteroRouter.mjs new file mode 100644 index 0000000000..882a602555 --- /dev/null +++ b/services/web/modules/zotero/app/src/ZoteroRouter.mjs @@ -0,0 +1,27 @@ +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import ZoteroController from './ZoteroController.mjs' + +export default { + apply(webRouter) { + // Get Zotero groups for the create-file modal + webRouter.get( + '/zotero/groups', + AuthenticationController.requireLogin(), + ZoteroController.getGroups + ) + + // Link Zotero account (a user submits an API key) + webRouter.post( + '/zotero/link', + AuthenticationController.requireLogin(), + ZoteroController.link + ) + + // Unlink Zotero account + webRouter.post( + '/zotero/unlink', + AuthenticationController.requireLogin(), + ZoteroController.unlink + ) + }, +} diff --git a/services/web/modules/zotero/frontend/js/components/tpr-file-view-info.tsx b/services/web/modules/zotero/frontend/js/components/tpr-file-view-info.tsx new file mode 100644 index 0000000000..995e128885 --- /dev/null +++ b/services/web/modules/zotero/frontend/js/components/tpr-file-view-info.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next' +import { formatTime, relativeDate } from '@/features/utils/format-date' +import { LinkedFileIcon } from '@/features/file-view/components/file-view-icons' +import type { BinaryFile } from '@/features/file-view/types/binary-file' + +type TPRFileViewInfoProps = { + file: BinaryFile +} + +/** + * Shows "Imported from Zotero at " in the file view header + * when viewing a Zotero-linked .bib file. + * Registered via overleafModuleImports.tprFileViewInfo. + */ +export function TPRFileViewInfo({ file }: TPRFileViewInfoProps) { + const { t } = useTranslation() + + if (file.linkedFileData?.provider !== 'zotero') { + return null + } + + const importedAt = (file.linkedFileData as any)?.importedAt || file.created + const formattedDate = formatTime(importedAt) + const relative = relativeDate(importedAt) + + const groupId = (file.linkedFileData as any)?.zoteroGroupId + + return ( +

+

+ + +   + {t('imported_from_zotero_at_date', { + formattedDate, + relativeDate: relative, + })} + + {groupId && ( + (Group: {groupId}) + )} +
+

+ ) +} diff --git a/services/web/modules/zotero/frontend/js/components/tpr-file-view-not-original-importer.tsx b/services/web/modules/zotero/frontend/js/components/tpr-file-view-not-original-importer.tsx new file mode 100644 index 0000000000..fa571440e2 --- /dev/null +++ b/services/web/modules/zotero/frontend/js/components/tpr-file-view-not-original-importer.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from 'react-i18next' +import type { BinaryFile } from '@/features/file-view/types/binary-file' +import OLNotification from '@/shared/components/ol/ol-notification' +import getMeta from '@/utils/meta' + +type TPRFileViewNotOriginalImporterProps = { + file: BinaryFile +} + +/** + * Shows a warning if the current user is not the original importer of this Zotero file. + * Registered via overleafModuleImports.tprFileViewNotOriginalImporter. + */ +export function TPRFileViewNotOriginalImporter({ + file, +}: TPRFileViewNotOriginalImporterProps) { + const provider = file.linkedFileData?.provider + const { t } = useTranslation() + + if (provider !== 'zotero') { + return null + } + + const currentUserId = + (getMeta('ol-user') as any)?._id || (getMeta('ol-user_id') as string) + const importedByUserId = (file.linkedFileData as any)?.importedByUserId + + if (provider !== 'zotero') { + return null + } + + if (!importedByUserId) { + return ( +
+ +
+ ) + } +// TODO: fetch the collaborator's name and show it + if (currentUserId && importedByUserId !== currentUserId) { + return ( +
+ +
+ ) + } + + return null +} diff --git a/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-button.tsx b/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-button.tsx new file mode 100644 index 0000000000..c20e88306a --- /dev/null +++ b/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-button.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next' +import OLButton from '@/shared/components/ol/ol-button' +import type { BinaryFile } from '@/features/file-view/types/binary-file' +import getMeta from '@/utils/meta' + +type TPRFileViewRefreshButtonProps = { + file: BinaryFile + refreshFile: (isTPR: boolean | null) => void + refreshing: boolean +} + +/** + * Zotero-specific refresh button for the file view. + * Tells the system this is a TPR file so references are re-indexed. + * Registered via overleafModuleImports.tprFileViewRefreshButton. + */ +export function TPRFileViewRefreshButton({ + file, + refreshFile, + refreshing, +}: TPRFileViewRefreshButtonProps) { + const { t } = useTranslation() + const provider = (file.linkedFileData as Record)?.provider + const currentUserId = + (getMeta('ol-user') as any)?._id || (getMeta('ol-user_id') as string) + const importedByUserId = (file.linkedFileData as any)?.importedByUserId + const isOriginalImporter = + provider === 'zotero' && + currentUserId && + importedByUserId === currentUserId + + if (provider !== 'zotero' || isOriginalImporter) { + // Zotero or default refresh for originator only + return ( + refreshFile(provider === 'zotero' ? true : null)} + disabled={refreshing} + isLoading={refreshing} + loadingLabel={t('refreshing')} + > + {t('refresh')} + + ) + } + + // collaborator of Zotero file should not refresh + return ( + + {t('refresh')} + + ) +} diff --git a/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-error.tsx b/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-error.tsx new file mode 100644 index 0000000000..f35196923e --- /dev/null +++ b/services/web/modules/zotero/frontend/js/components/tpr-file-view-refresh-error.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import OLNotification from '@/shared/components/ol/ol-notification' +import type { BinaryFile } from '@/features/file-view/types/binary-file' + +type TPRFileViewRefreshErrorProps = { + file: BinaryFile + refreshError: string +} + +/** + * Zotero-specific error messages when refreshing a linked file fails. + * Registered via overleafModuleImports.tprFileViewRefreshError. + */ +export function TPRFileViewRefreshError({ + file, + refreshError, +}: TPRFileViewRefreshErrorProps) { + const { t } = useTranslation() + + if (file.linkedFileData?.provider !== 'zotero') { + // Not a Zotero file — fall back to default error display + return ( +
+ +
+ ) + } + + let message = refreshError + if (refreshError === 'forbidden' || refreshError?.includes('403')) { + message = t('zotero_reference_loading_error_forbidden') + } else if ( + refreshError === 'expired' || + refreshError?.includes('token expired') + ) { + message = t('zotero_reference_loading_error_expired') + } else if (!message) { + message = t('zotero_reference_loading_error') + } + + return ( +
+ +
+ ) +} diff --git a/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx b/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx new file mode 100644 index 0000000000..cae993df60 --- /dev/null +++ b/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx @@ -0,0 +1,144 @@ +import { useTranslation } from 'react-i18next' +import { FormEventHandler, useEffect, useState, useCallback } from 'react' +import { getJSON } from '@/infrastructure/fetch-json' +import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable' +import { useFileTreeCreateForm } from '@/features/file-tree/contexts/file-tree-create-form' +import { useFileTreeMainContext } from '@/features/file-tree/contexts/file-tree-main' +import FileTreeModalCreateFileMode from '@/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode' +import ErrorMessage from '@/features/file-tree/components/file-tree-create/error-message' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import OLFormSelect from '@/shared/components/ol/ol-form-select' +import OLNotification from '@/shared/components/ol/ol-notification' + +type ZoteroGroup = { + id: string + name: string +} + +export function CreateFileMode() { + const { refProviders } = useFileTreeMainContext() + const isLinked = (refProviders as Record)?.zotero + + if (!isLinked) { + return null + } + + return ( + + ) +} + +export function CreateFilePane() { + const { t } = useTranslation() + const { setValid } = useFileTreeCreateForm() + const { newFileCreateMode, finishCreatingLinkedFile, error, inFlight } = + useFileTreeActionable() + + const [groups, setGroups] = useState([]) + const [selectedGroupId, setSelectedGroupId] = useState('') + const [name, setName] = useState('zotero.bib') + const [loadingGroups, setLoadingGroups] = useState(true) + const [groupsError, setGroupsError] = useState('') + + // form validation + useEffect(() => { + setValid(!!name && !loadingGroups) + }, [setValid, name, loadingGroups]) + + // load groups when the mode is active + useEffect(() => { + if (newFileCreateMode !== 'zotero') return + + setLoadingGroups(true) + setGroupsError('') + getJSON('/zotero/groups') + .then((data: { groups: ZoteroGroup[] }) => { + setGroups(data.groups || []) + setLoadingGroups(false) + }) + .catch(() => { + setGroupsError(t('zotero_groups_loading_error')) + setLoadingGroups(false) + }) + }, [newFileCreateMode, t]) + + const handleSubmit: FormEventHandler = useCallback( + event => { + event.preventDefault() + + const data: Record = {} + if (selectedGroupId) { + data.zoteroGroupId = selectedGroupId + } + + finishCreatingLinkedFile({ + name, + provider: 'zotero', + data, + }) + }, + [name, selectedGroupId, finishCreatingLinkedFile] + ) + + if (newFileCreateMode !== 'zotero') { + return null + } + + return ( +
+ {groupsError && ( + + )} + + + {t('file_name')} + ) => + setName(e.target.value) + } + /> + + + + Library + {loadingGroups ? ( +

{t('loading')}...

+ ) : ( + ) => + setSelectedGroupId(e.target.value) + } + > + + {groups.map(g => ( + + ))} + + )} +
+ + {error && } + + ) +} diff --git a/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx b/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx new file mode 100644 index 0000000000..2e28cbc85c --- /dev/null +++ b/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx @@ -0,0 +1,135 @@ +import { useTranslation } from 'react-i18next' +import { useCallback, useState } from 'react' +import { postJSON } from '@/infrastructure/fetch-json' +import getMeta from '@/utils/meta' +import OLButton from '@/shared/components/ol/ol-button' +import OLNotification from '@/shared/components/ol/ol-notification' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import ZoteroLogo from '@/shared/svgs/zotero-logo' + +/** + * Zotero account linking widget for the Account Settings page. + * Instead of OAuth, users paste their Zotero API key directly. + * Create one at https://www.zotero.org/settings/keys with: + * - "Allow library access" + * - "Allow read access to all groups" (for group library imports) + * + * Registered via overleafModuleImports.referenceLinkingWidgets. + */ +export default function ZoteroWidget() { + const { t } = useTranslation() + const user = getMeta('ol-user') + const refProviders = user?.refProviders || {} + const [isLinked, setIsLinked] = useState(Boolean(refProviders.zotero)) + const [apiKey, setApiKey] = useState('') + const [processing, setProcessing] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + + const handleLink = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + if (!apiKey) return + setProcessing(true) + setError('') + setSuccess('') + try { + await postJSON('/zotero/link', { body: { apiKey } }) + setSuccess(t('zotero_account_linked_successfully')) + setApiKey('') + setProcessing(false) + setIsLinked(true) + } catch (err: any) { + const msg = + err?.data?.error === 'invalid_api_key' + ? 'Invalid API key. Please check your key and try again.' + : t('generic_something_went_wrong') + setError(msg) + setProcessing(false) + } + }, + [apiKey, t] + ) + + const handleUnlink = useCallback(async () => { + setProcessing(true) + setError('') + setSuccess('') + try { + await postJSON('/zotero/unlink') + setProcessing(false) + setIsLinked(false) + } catch (err) { + setError(t('generic_something_went_wrong')) + setProcessing(false) + } + }, [t]) + + return ( +
+
+ +
+
+
+

{t('zotero')}

+
+

+ {t('zotero_sync_description', { + appName: + getMeta('ol-ExposedSettings')?.appName || 'Overleaf', + })} +

+ {error && } + {success && } + {!isLinked && ( +
+

+ Create an API key at{' '} + + zotero.org/settings/keys + {' '} + with Allow library access and{' '} + Allow read access to all groups enabled. +

+
+ ) => + setApiKey(e.target.value.trim()) + } + disabled={processing} + autoComplete="off" + /> +
+ + {t('link_to_zotero')} + +
+ )} +
+
+ {isLinked && ( + + {t('unlink')} + + )} +
+
+ ) +} diff --git a/services/web/modules/zotero/index.mjs b/services/web/modules/zotero/index.mjs new file mode 100644 index 0000000000..db8a035d17 --- /dev/null +++ b/services/web/modules/zotero/index.mjs @@ -0,0 +1,19 @@ +import Settings from '@overleaf/settings' +import ZoteroRouter from './app/src/ZoteroRouter.mjs' + +let ZoteroModule = {} + +if (Settings.enabledLinkedFileTypes?.includes('zotero')) { + const { default: ZoteroLinkedFileAgent } = await import( + './app/src/ZoteroLinkedFileAgent.mjs' + ) + + ZoteroModule = { + router: ZoteroRouter, + linkedFileAgents: { + zotero: () => ZoteroLinkedFileAgent, + }, + } +} + +export default ZoteroModule diff --git a/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs index 98e0bf7845..2f37e5026b 100644 --- a/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs +++ b/services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs @@ -1,5 +1,6 @@ import { expect, vi } from 'vitest' import sinon from 'sinon' +import LinkedFilesErrors from '../../../../app/src/Features/LinkedFiles/LinkedFilesErrors.mjs' const modulePath = '../../../../app/src/Features/LinkedFiles/LinkedFilesController.mjs' @@ -224,6 +225,37 @@ describe('LinkedFilesController', function () { ) }) }) + + it('returns error 400 if user is not original importer', async function (ctx) { + ctx.Agent.promises.refreshLinkedFile = sinon + .stub() + .rejects( + new LinkedFilesErrors.NotOriginalImporterError( + 'Only the user who created the Zotero-linked file can refresh this file' + ) + ) + + await new Promise(resolve => { + ctx.next = sinon.stub().callsFake(() => resolve('unexpected error')) + ctx.res = { + status: sinon.stub().callsFake(code => { + expect(code).to.equal(400) + return ctx.res + }), + contentType: sinon.stub(), + setHeader: sinon.stub(), + send: sinon.stub(msg => { + expect(msg).to.equal('You are not the user who originally imported this file') + resolve() + }), + } + + ctx.LinkedFilesController.refreshLinkedFile(ctx.req, ctx.res, ctx.next) + }) + }) + }) + + expect(ctx.Agent.promises.refreshLinkedFile).to.not.have.been.called }) }) })