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
+
+ {t('zotero_sync_description', { + appName: + getMeta('ol-ExposedSettings')?.appName || 'Overleaf', + })} +
+ {error &&