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>Zotero0> and <0>Mendeley0> 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 (
+
+ )
+}
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 && (
+
+ )}
+
+
+ {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
})
})
})