From 9afab5ade14cbcf9cf1a32d04f99a035598bf6a6 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 7 Jul 2026 00:43:33 +0200 Subject: [PATCH] Zotero: improve integration and reference import - use OAuth instead of tokens - add an integration card - improve error handling - add BibLaTeX format support - fix indexing of linked .bib files on creation --- .../LinkedFiles/LinkedFilesController.mjs | 18 +- services/web/config/settings.defaults.js | 4 + .../web/frontend/extracted-translations.json | 1 + .../contexts/file-tree-actionable.tsx | 8 +- .../features/file-view/types/binary-file.ts | 5 +- services/web/locales/de.json | 7 +- services/web/locales/en.json | 7 +- services/web/locales/ru.json | 12 + .../app/src/AccessTokenEncryptorHelper.mjs | 96 ++++--- .../modules/zotero/app/src/TokenManager.mjs | 43 +++ .../zotero/app/src/ZoteroApiClient.mjs | 269 +++++++++--------- .../zotero/app/src/ZoteroController.mjs | 182 ++++++++---- .../zotero/app/src/ZoteroLinkedFileAgent.mjs | 84 ++++-- .../modules/zotero/app/src/ZoteroOAuth.mjs | 125 ++++++++ .../modules/zotero/app/src/ZoteroRouter.mjs | 33 ++- .../file-tree-import-from-zotero.tsx | 112 ++++++++ .../js/components/tpr-file-view-info.tsx | 12 +- .../tpr-file-view-not-original-importer.tsx | 1 - .../tpr-file-view-refresh-button.tsx | 6 +- .../tpr-file-view-refresh-error.tsx | 9 +- .../js/components/zotero-create-file.tsx | 230 ++++++--------- .../js/components/zotero-integration-card.tsx | 61 ++++ .../js/components/zotero-link-info-modal.tsx | 109 +++++++ .../frontend/js/components/zotero-widget.tsx | 253 +++++++++------- services/web/modules/zotero/index.mjs | 7 + services/web/package.json | 1 + 26 files changed, 1153 insertions(+), 542 deletions(-) create mode 100644 services/web/modules/zotero/app/src/TokenManager.mjs create mode 100644 services/web/modules/zotero/app/src/ZoteroOAuth.mjs create mode 100644 services/web/modules/zotero/frontend/js/components/file-tree-import-from-zotero.tsx create mode 100644 services/web/modules/zotero/frontend/js/components/zotero-integration-card.tsx create mode 100644 services/web/modules/zotero/frontend/js/components/zotero-link-info-modal.tsx diff --git a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs index c0248196b5..9c68858859 100644 --- a/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs +++ b/services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs @@ -368,7 +368,23 @@ export default LinkedFilesController = { error.message === 'Zotero account not linked' ) { res.status(400) - plainTextResponse(res, 'Your account is not linked with Zotero') + plainTextResponse( + res, + res.locals.translate( + 'zotero_reference_loading_error_not_linked' + ) + ) + } else if ( + error instanceof AccessDeniedError && + error.message === 'Zotero access denied' + ) { + res.status(403) + plainTextResponse( + res, + res.locals.translate( + 'zotero_reference_loading_error_forbidden' + ) + ) } else if (error instanceof AccessDeniedError) { res.status(403) plainTextResponse( diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index bdb3f6985b..2ce907503e 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1163,6 +1163,10 @@ module.exports = { __dirname, '../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx' ), + Path.resolve( + __dirname, + '../modules/zotero/frontend/js/components/zotero-integration-card.tsx' + ), ], referenceSearchSetting: [], settingsModalEditorTabSections: [], diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index d87aca0eaa..59da3ea233 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1646,6 +1646,7 @@ "project_actions": "", "project_approaching_file_limit": "", "project_categories_tags": "", + "problem_checking_connection_with_provider": "", "project_figure_modal": "", "project_files_history": "", "project_flagged_too_many_compiles": "", diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx index 5fcabe1653..c0b77ceae9 100644 --- a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx @@ -487,7 +487,13 @@ export const FileTreeActionableProvider: FC = ({ const finishCreatingLinkedFile = useCallback( (entity: Omit) => { - return finishCreatingDocOrFile({ ...entity, endpoint: 'linked_file' }) + const shouldReindexReferences = /\.bib$/.test(entity.name) + return finishCreatingDocOrFile({ ...entity, endpoint: 'linked_file' }). + then(() => { + if (shouldReindexReferences) { + indexAllReferences(true) + } + }) }, [finishCreatingDocOrFile] ) diff --git a/services/web/frontend/js/features/file-view/types/binary-file.ts b/services/web/frontend/js/features/file-view/types/binary-file.ts index 6e709d0932..d49bce2e62 100644 --- a/services/web/frontend/js/features/file-view/types/binary-file.ts +++ b/services/web/frontend/js/features/file-view/types/binary-file.ts @@ -19,8 +19,9 @@ export type LinkedFileData = { provider: 'zotero' zoteroGroupId?: string importedAt: Date | string - importedByUserId?: string - importedByName?: string + importedByUserId: string + importedByName: string + bibFormat: 'bibtex' | 'biblatex' } } diff --git a/services/web/locales/de.json b/services/web/locales/de.json index cfbac0214f..7d067bc861 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -306,6 +306,7 @@ "choose_a_custom_color": "Wähle eine eigene Farbe", "choose_your_plan": "Wähle deinen Kontotyp", "citation": "Zitat", + "cite_directly_or_import_references": "Referenzen importieren", "city": "Stadt", "clear_cached_files": "Zwischengespeicherte Dateien löschen", "clear_search": "Suche löschen", @@ -1312,6 +1313,7 @@ "private": "Privat", "pro": "Pro", "problem_changing_email_address": "Es gab ein Problem beim Ändern deiner E-Mail-Adresse. Bitte versuche es in ein paar Minuten erneut. Wenn das Problem bestehen bleibt, kontaktiere uns bitte.", + "problem_checking_connection_with_provider": "Beim Überprüfen der Verbindung mit dem __provider__-Dienst ist ein Problem aufgetreten. Bitte versuchen Sie es in einigen Augenblicken erneut.", "problem_talking_to_publishing_service": "Es gibt ein Problem mit unserem Veröffentlichungsservice. Bitte versuche es in einigen Minuten noch einmal", "problem_with_subscription_contact_us": "Es gibt ein Problem mit deinem Abonnement. Bitte kontaktiere uns für mehr Informationen.", "processing": "in Bearbeitung", @@ -1743,9 +1745,9 @@ "unlink_github_warning": "Bei allen Projekten, die mit GitHub synchronisiert sind, wird die Verlinkung entfernt und nicht länger mit GitHub synchronisiert. Bist du sicher, dass du die Verbindung zu deinem GitHub-Nutzerkonto lösen möchtest?", "unlink_provider_account_title": "__provider__-Konto verknüpfen", "unlink_provider_account_warning": "Warnung: Wenn du die Verknüpfung deines Kontos mit __provider__ aufhebst, kannst du dich nicht mehr mit __provider__ anmelden.", - "unlink_reference": "Link zum Referenzengeber entfernen", + "unlink_reference": "Link zum Referenzengeber __provider__ entfernen", "unlink_sso": "SSO-Verknüpfung aufheben", - "unlink_warning_reference": "Achtung: Wenn du dein Konto von diesem Anbieter entkoppelst, wirst du nicht in der Lage sein, Referenzen in deine Projekte zu importieren.", + "unlink_warning_reference": "Achtung: Wenn du dein Konto von __provider__ entkoppelst, wirst du nicht in der Lage sein, Referenzen in deine Projekte zu importieren.", "unlinking": "Verknüpfung wird aufgehoben", "unpause_subscription": "Abonnement fortsetzen", "unpausing": "Fortsetzen", @@ -1865,6 +1867,7 @@ "you_can_still_use_your_premium_features": "Du kannst deine Premiumfunktionen weiterhin nutzen, bis die Pause aktiv wird.", "you_cant_add_or_change_password_due_to_sso": "Du kannst dein Passwort nicht hinzufügen oder ändern, da deine Gruppe oder Organisation <0>Single Sign-On (SSO) verwendet.", "you_cant_overwrite_it": "Überschreiben ist nicht möglich.", + "you_currently_have_x_linked_with_your_overleaf_account": "Sie haben derzeit <0>__managers__ mit Ihrem __appName__ Konto verknüpft.", "you_dont_have_any_repositories": "Du hast keine Repositories", "you_have_added_x_of_group_size_y": "Du hast <0>__addedUsersSize__ von <1>__groupSize__ verfügbaren Mitgliedern hinzugefügt", "you_unpaused_your_subscription": "Du hast dein Abonnement fortgesetzt.", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 75050119be..0dde85c1f0 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -418,7 +418,7 @@ "citation": "Citation", "citation_key_autogenerate_helper": "Auto-generated from the author and year, if left blank", "cite_a_reference_from_your_reference_manager_to_automatically_add_it_to_your_bib_file": "Cite a reference from your reference manager to automatically add it to your .bib file.", - "cite_directly_or_import_references": "Cite directly or import references", + "cite_directly_or_import_references": "Import references", "cite_faster": "Cite faster", "city": "City", "clear": "Clear", @@ -2163,6 +2163,7 @@ "private": "Private", "pro": "Pro", "problem_changing_email_address": "There was a problem changing your email address. Please try again in a few moments. If the problem continues please contact us.", + "problem_checking_connection_with_provider": "Sorry, there was a problem checking the connection with __provider__ service. Please try again in a few moments.", "problem_talking_to_publishing_service": "There is a problem with our publishing service, please try again in a few minutes", "problem_with_subscription_contact_us": "There is a problem with your subscription. Please contact us for more information.", "proceed_to_paypal": "Proceed to PayPal", @@ -3158,12 +3159,12 @@ "unlink_linked_google_account": "Unlink your Google account. <0>Remove it in Account settings (under Linked Accounts).", "unlink_provider_account_title": "Unlink __provider__ Account", "unlink_provider_account_warning": "Warning: When you unlink your account from __provider__ you will not be able to sign in using __provider__ anymore.", - "unlink_reference": "Unlink References Provider", + "unlink_reference": "Unlink __provider__ References Provider", "unlink_sso": "Unlink SSO", "unlink_the_project_from_the_current_github_repo": "Unlink the project from the current GitHub repository and create a connection to a repository you own.", "unlink_user_explanation": "You’re about to remove the SSO login option for <0>__email__. This will force them to reauthenticate their Overleaf account with your IdP. They’ll receive an email asking them to do this.", "unlink_users": "Unlink users", - "unlink_warning_reference": "Warning: When you unlink your account from this provider you will not be able to import references into your projects.", + "unlink_warning_reference": "Warning: When you unlink your account from __provider__ you will not be able to import references into your projects.", "unlinking": "Unlinking", "unmerge_cells": "Unmerge cells", "unpause_subscription": "Unpause subscription", diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index e29d5d46d4..5d00f09a45 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -122,6 +122,7 @@ "checking_dropbox_status": "Проверка состояния Dropbox", "checking_project_github_status": "Проверка статуса проекта на GitHub", "choose_your_plan": "Выберите тариф", + "cite_directly_or_import_references": "Импортировать ссылки", "city": "Город", "clear_cached_files": "Очистить кэшированные файлы", "clear_sessions": "Завершить сессии", @@ -229,6 +230,8 @@ "features": "Возможности", "features_like_track_changes": "Такие функции, как отслеживание изменений в реальном времени", "fi": "Финский", + "file_name": "Имя файла", + "file_name_in_this_project": "Имя файла в этом проекте", "files_cannot_include_invalid_characters": "Файлы не могут содержать символы «*» и «/»", "filter_projects": "Фильтровать проекты", "filter_users": "Фильтровать пользователей", @@ -242,6 +245,7 @@ "free_7_day_trial_billed_annually": "Бесплатный пробный период 7 дней, затем ежегодная оплата", "free_7_day_trial_billed_monthly": "Бесплатный пробный период 7 дней, затем ежемесячная оплата", "free_dropbox_and_history": "Бесплатные Dropbox и История", + "from_provider": "Из __provider__", "full_doc_history": "Полная история изменений", "full_project_history": "Полная история проекта", "general": "Общие", @@ -364,6 +368,7 @@ "i_want_to_stay": "Я хочу остаться", "ignore_validation_errors": "Не проверять синтаксис", "ill_take_it": "Беру!", + "import_a_bibtex_file_from_your_provider_account": "Импортируйте BibTeX файл из Вашего аккаунта __provider__", "import_from_github": "Импорт с GitHub", "import_to_sharelatex": "Импортировать в __appName__", "imported_from_zotero_at_date": "Импортировано из Zotero в __formattedDate__ __relativeDate__", @@ -404,6 +409,7 @@ "leave_now": "Покинуть", "leave_projects": "Оставить проекты", "lets_get_those_premium_features": "Давайте сразу подключим Ваши премиум-функции. Мы спишем <0>__paymentAmount__ с указанных Вами платёжных данных.", + "library": "Библиотека", "link_to_github": "Привязать аккаунт на GitHub", "link_to_github_description": "Вам необходимо авторизовать __appName__ для доступа к Вашему GitHub аккаунту, чтобы разрешить нам синхронизацию Ваших проектов.", "links": "Ссылки", @@ -451,6 +457,7 @@ "more_compile_time": "Больше времени компиляции", "more_options": "Дополнительные параметры", "must_be_email_address": "Введите правильный адрес электронной почты.", + "my_library": "Моя библиотека", "name": "Имя", "native": "браузер", "navigation": "Навигация", @@ -548,6 +555,7 @@ "private": "Закрытый", "pro": "Pro", "problem_changing_email_address": "Возникла проблема при обновлении Вашего адреса электронной почты. Пожалуйста, попробуйте через некоторое время снова. Если проблема повторится, пожалуйста свяжитесь с нами.", + "problem_checking_connection_with_provider": "При проверке соединения со службой __provider__ возникла проблема. Пожалуйста, повторите попытку через некоторое время.", "problem_talking_to_publishing_service": "Проблема с сервером публикации. Пожалуйста, попробуйте через некоторое время ещё раз", "problem_with_subscription_contact_us": "Возникли проблемы с Вашей подпиской. Пожалуйста, свяжитесь с нами, чтобы узнать подробности.", "processing": "обработка", @@ -751,6 +759,8 @@ "unlink": "Отсоединить", "unlink_github_repository": "Отсоединить Git-репозиторий", "unlink_github_warning": "Все проекты, которые Вы синхронизировали с GitHub, будут отсоединены и больше не будут синхронизироваться с GitHub. Вы уверены, что хотите отсоединить Ваш GitHub аккаунт?", + "unlink_reference": "Отсоединить провайдер ссылок __provider__", + "unlink_warning_reference": "Если вы отсоедините аккаунт, у Вас не будет возможности импортировать ссылки из __provider__ в Ваши проекты.", "unpause_subscription": "Возобновить подписку", "unpausing": "Возобновление", "unpublish": "Отменить публикацию", @@ -830,6 +840,7 @@ "you_are_using_our_free_plan": "Вы пользуетесь бесплатным тарифом. Смените тариф, чтобы получить максимум от Overleaf", "you_can_still_use_your_premium_features": "Вы сможете пользоваться премиум-функциями до того, как приостановка вступит в силу.", "you_cant_overwrite_it": "Перезаписать нельзя.", + "you_currently_have_x_linked_with_your_overleaf_account": "Для вашей учётной записи __appName__ в настоящее время настроена интеграция с <0>__managers__.", "you_dont_have_any_repositories": "У вас нет ни одного репозитория", "you_have_added_x_of_group_size_y": "Вы добавили <0>__addedUsersSize__ из <1>__groupSize__ доступных участников", "you_unpaused_your_subscription": "Вы возобновили подписку.", @@ -857,6 +868,7 @@ "zh-CN": "Китайский", "zotero_account_linked_successfully": "Ваш аккаунт Zotero успешно подключён.", "zotero_groups_loading_error": "При загрузке групп из Zotero произошла ошибка", + "zotero_groups_relink": "При загрузке данных из Zotero произошла ошибка, по-видимости вызванная отсутствием разрешения на доступ. Пожалуйста, подключите аккаунт заново и попробуйте ещё раз.", "zotero_reference_loading_error": "Ошибка: не удалось загрузить ссылки из Zotero", "zotero_reference_loading_error_expired": "Срок действия токена Zotero истёк, пожалуйста, повторно подключите аккаунт", "zotero_reference_loading_error_forbidden": "Не удалось загрузить ссылки из Zotero, пожалуйста, повторно подключите аккаунт и попробуйте снова", diff --git a/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs b/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs index a12dc296b0..d4e563d833 100644 --- a/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs +++ b/services/web/modules/zotero/app/src/AccessTokenEncryptorHelper.mjs @@ -1,66 +1,82 @@ -import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor' +import logger from '@overleaf/logger' import Settings from '@overleaf/settings' import fs from 'node:fs' import crypto from 'node:crypto' import Path from 'node:path' +import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor' -const CIPHER_KEY_FILE = '/var/lib/overleaf/data/.zotero-cipher-key' -const CIPHER_LABEL = '2024.1-v3' +const TOKEN_CIPHER_FILE = '/var/lib/overleaf/data/.token-cipher.json' +const TOKEN_CIPHER_LABEL = 'OL_CEP-v3' let encryptorInstance = null /** - * Get or create a stable cipher password that persists across container + * Get or create a stable encryptor data 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/) + * 2. JSON file with encryptor data 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 +function _getEncryptorData() { + const cipherPassword = process.env.ZOTERO_TOKEN_CIPHER_PASSWORD || process.env.TOKEN_CIPHER_PASSWORD + const cipherLabel = process.env.ZOTERO_TOKEN_CIPHER_LABEL || + process.env.TOKEN_CIPHER_LABEL || + TOKEN_CIPHER_LABEL + if (cipherPassword) { + return { + cipherLabel, + cipherPasswords: { + [cipherLabel]: cipherPassword + } } - } 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 + // cipherPassword is not set, use file + const cipherFile = process.env.ZOTERO_TOKEN_CIPHER_FILE || + process.env.TOKEN_CIPHER_FILE || + TOKEN_CIPHER_FILE + try { + return JSON.parse(fs.readFileSync(cipherFile, 'utf8')) + } catch (err) { + if (err.code === 'ENOENT') { + logger.info({ cipherFile }, 'Creating new cipherFile') + } else { + logger.error({ err, cipherFile }, 'Bad cipherFile') + throw err + } + } + // File doesn't exist yet — generate one + try { + const encryptorData = { + cipherLabel, + cipherPasswords: { + [cipherLabel]: crypto.randomBytes(32).toString('base64') + }, + } + + const dir = Path.dirname(cipherFile) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(cipherFile, JSON.stringify(encryptorData, null, 2), { mode: 0o600 }) + return encryptorData + + } catch (err) { + logger.error({ err, cipherFile }, 'Cannot create cipherFile') + throw err + } } 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) - } + const encryptorData = _getEncryptorData() + encryptorInstance = new AccessTokenEncryptorClass(encryptorData) } return encryptorInstance } export const AccessTokenEncryptor = { - promises: { - async encryptJson(json) { - return await _getEncryptor().promises.encryptJson(json) - }, - async decryptToJson(encryptedJson) { - return await _getEncryptor().promises.decryptToJson(encryptedJson) - }, + 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/TokenManager.mjs b/services/web/modules/zotero/app/src/TokenManager.mjs new file mode 100644 index 0000000000..244af15c73 --- /dev/null +++ b/services/web/modules/zotero/app/src/TokenManager.mjs @@ -0,0 +1,43 @@ +import logger from '@overleaf/logger' +import { User } from '../../../../app/src/models/User.mjs' +import { AccessTokenEncryptor } from './AccessTokenEncryptorHelper.mjs' + +/** + * 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.decryptToJson( + user.refProviders.zotero.apiKeyEncrypted + ) + return decrypted + } catch (err) { + logger.error({ userId, err }, 'failed to decrypt Zotero credentials, treating as not connected') + return null + } +} + +/** + * Link a Zotero account (store encrypted credentials). + */ +async function storeCredentials(userId, apiKey, zoteroUserId) { + const apiKeyEncrypted = await AccessTokenEncryptor.encryptJson({ + apiKey, + zoteroUserId: String(zoteroUserId), + }) + await User.updateOne( + { _id: userId }, + { $set: { 'refProviders.zotero': { apiKeyEncrypted } } } + ).exec() +} + + +export default { + getCredentials, + storeCredentials, +} diff --git a/services/web/modules/zotero/app/src/ZoteroApiClient.mjs b/services/web/modules/zotero/app/src/ZoteroApiClient.mjs index 863951904d..373c6ac782 100644 --- a/services/web/modules/zotero/app/src/ZoteroApiClient.mjs +++ b/services/web/modules/zotero/app/src/ZoteroApiClient.mjs @@ -1,208 +1,197 @@ import logger from '@overleaf/logger' -import Settings from '@overleaf/settings' import OError from '@overleaf/o-error' -import { fetchJson, fetchString } from '@overleaf/fetch-utils' +import AbortError from 'node-fetch' +import { + fetchNothing, + fetchJson, + fetchString, + fetchStringWithResponse, + RequestFailedError, +} from '@overleaf/fetch-utils' import { User } from '../../../../app/src/models/User.mjs' -import { AccessTokenEncryptor } from './AccessTokenEncryptorHelper.mjs' +import { + NotFoundError, + TooManyRequestsError, + ServiceNotConfiguredError, + ForbiddenError, +} from '../../../../app/src/Features/Errors/Errors.js' +import TokenManager from './TokenManager.mjs' const ZOTERO_API_URL = 'https://api.zotero.org' +const REQUEST_TIMEOUT_MS = 60 * 1000 +// TODO: implement conditional requests /** - * Decrypt stored Zotero credentials from user record. - * Returns { apiKey, zoteroUserId } or null if not linked. + * Build a header for Zotero API request. */ -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}` +function buildHeaders(apiKey, opts = {}) { const headers = { 'Zotero-API-Version': '3', 'Zotero-API-Key': apiKey, - ...(opts.headers || {}), + 'User-Agent': 'Overleaf-CEP-Zotero', + ...opts, + } + return headers +} + +/** + * Checks connection to Zotero by calling /keys/{key}. + */ +async function getConnectionStatus(userId) { + const credentials = await TokenManager.getCredentials(userId) + if (!credentials) return false + + const { apiKey } = credentials + try { + await fetchJson(`${ZOTERO_API_URL}/keys/${apiKey}`, { + headers: buildHeaders(apiKey), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + return true + } catch (err) { + normalizeApiError(err, 'getConnectionStatus') } - 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 credentials = await TokenManager.getCredentials(userId) + if (!credentials) return null + const { apiKey, zoteroUserId } = credentials - const { url, headers } = await _zoteroApiRequest( - apiKey, - `/users/${zoteroUserId}/groups` - ) try { - const groups = await fetchJson(url, { headers }) + const groups = await fetchJson(`${ZOTERO_API_URL}/users/${zoteroUserId}/groups`, { + headers: buildHeaders(apiKey), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }) 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') + normalizeApiError(err, 'getGroupsForUser') } } /** - * Export the user's entire library as BibTeX. + * Export a library as BibTeX / BibLaTeX. */ -async function getUserLibraryBibtex(userId) { - const credentials = await _getCredentials(userId) +async function getLibraryBibtex(userId, groupId, format) { + const credentials = await TokenManager.getCredentials(userId) if (!credentials) { - throw new ZoteroAccountNotLinkedError() + throw new ServiceNotConfiguredError({ + message: 'RefProvider credentials missed', + info: { userId, status: 400 } + }) } - 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`) + // Main library or group? + let basePath + if (groupId) basePath = `/groups/${groupId}/items` + else basePath = `/users/${credentials.zoteroUserId}/items` + + return _fetchBibtex(credentials.apiKey, basePath, format) } /** * 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 +async function _fetchBibtex(apiKey, basePath, format) { const limit = 100 + let allBibtex = '' - 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 - ) + try { + let start = 0 + const headers = buildHeaders(apiKey) + while (true) { + const url = `${ZOTERO_API_URL}${basePath}?format=${format}&limit=${limit}&start=${start}` + const { body: bibtex, response } = await fetchStringWithResponse(url, { + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }) + + if (bibtex.trim()) allBibtex += bibtex + + 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 }) } + } catch (err) { + normalizeApiError(err, '_fetchBibtex') } 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) { + const credentials = await TokenManager.getCredentials(userId) + if (!credentials) return + await User.updateOne( { _id: userId }, { $unset: { 'refProviders.zotero': 1 } } ).exec() + + try { + const { apiKey } = credentials + await fetchNothing(`${ZOTERO_API_URL}/keys/${apiKey}`, { + method: 'DELETE', + headers: buildHeaders(apiKey), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + } catch (err) { + logger.error({ err }, 'failed to detete key from Zotero account') + } + } -export class ZoteroForbiddenError extends Error { - constructor(message) { - super(message) - this.name = 'ZoteroForbiddenError' - } -} +function normalizeApiError(err, operation) { + logger.error({ operation }, 'Zotero API request failed') -export class ZoteroAccountNotLinkedError extends Error { - constructor(message = 'Zotero account not linked') { - super(message) - this.name = 'ZoteroAccountNotLinkedError' + if (err.name === 'AbortError') { + throw new OError('RefProvider request timed out', { operation, status: 504 }).withCause(err) } + + if (!(err instanceof RequestFailedError)) { + throw new OError('Something wrong with RefProvider request', { operation, status: 500 }).withCause(err) + } + + const status = err.response?.status || 500 + + if (status === 403) { + throw new ForbiddenError({ + message: 'Access denied', + info: { operation, status } + }).withCause(err) + } + + if (status === 404) { + throw new NotFoundError({ + message: 'Not found', + info: { operation, status } + }).withCause(err) + } + + if (status === 429) { + throw new TooManyRequestsError({ + message: 'Rate limit exeeded', + info: { operation, status } + }).withCause(err) + } + + throw new OError('RefProvider request error', { operation, status }).withCause(err) } export default { + getConnectionStatus, getGroupsForUser, - getUserLibraryBibtex, - getGroupLibraryBibtex, - validateApiKey, - storeCredentials, + getLibraryBibtex, unlinkAccount, - ZoteroForbiddenError, - ZoteroAccountNotLinkedError, } diff --git a/services/web/modules/zotero/app/src/ZoteroController.mjs b/services/web/modules/zotero/app/src/ZoteroController.mjs index c2d834e6ea..c65a16c52d 100644 --- a/services/web/modules/zotero/app/src/ZoteroController.mjs +++ b/services/web/modules/zotero/app/src/ZoteroController.mjs @@ -1,71 +1,134 @@ import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import crypto from 'crypto' import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' +import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs' import ZoteroApiClient from './ZoteroApiClient.mjs' -import { ZoteroForbiddenError } from './ZoteroApiClient.mjs' +import ZoteroOAuth from './ZoteroOAuth.mjs' +import TokenManager from './TokenManager.mjs' + +async function oauth(req, res) { + try { + const requestToken = await ZoteroOAuth.getRequestToken() + const isPopup = req.query.popup === '1' + + req.session.zoteroOAuth = { + token: requestToken.oauth_token, + tokenSecret: requestToken.oauth_token_secret, + isPopup + } + + const authUrl = ZoteroOAuth.getAuthorizationUrl(requestToken.oauth_token) + res.redirect(authUrl) + + } catch (err) { + logger.error(OError.getFullStack(err)) + const info = OError.getFullInfo(err) + logger.error({ info }, "Failed to start Zotero authorization") + + HttpErrorHandler.badRequest(req, res, 'Failed to start Zotero authorization') + return + } +} + +async function oauthCallback(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + + const { + oauth_token: oauthToken, + oauth_verifier: oauthVerifier, + } = req.query + + const saved = req.session.zoteroOAuth + delete req.session.zoteroOAuth + + if (!saved || saved.token !== oauthToken) { + HttpErrorHandler.forbidden(req, res, 'Invalid OAuth token') + return + } + + try { + const { accessToken, zoteroUserId } = await ZoteroOAuth.exchangeRequestTokenForAccessToken( + oauthToken, + saved.tokenSecret, + oauthVerifier, + ) + await TokenManager.storeCredentials(userId, accessToken, zoteroUserId) + + } catch (err) { + logger.error(OError.getFullStack(err)) + const info = OError.getFullInfo(err) + logger.error({ info }, "Failed to obtain Zotero access token'") + + HttpErrorHandler.badRequest(req, res, 'Failed to obtain Zotero access token') + return + } + + const nonce = crypto.randomBytes(16).toString('base64') + const csp = res.getHeader('Content-Security-Policy') + + res.setHeader( + 'Content-Security-Policy', + `${csp}; script-src 'nonce-${nonce}'` + ) + + res.send(` + + + + + + + `) +} /** - * GET /zotero/groups - * Returns the user's Zotero groups (for the create-file modal). + * GET /user/zotero/status + * Returns the user's Zotero connection status, true or false + */ +async function getConnectionStatus(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + + try { + const isConnected = await ZoteroApiClient.getConnectionStatus(userId) + res.json(isConnected) + + } catch (err) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error(OError.getFullStack(err)) + logger.error({ info }, "failed to check user connection") + return res.status(errStatus).json({ message: err.message }) + } +} + +/** + * GET /user/zotero/groups + * Returns the user's Zotero groups or null, if account is not linked */ async function getGroups(req, res) { const userId = SessionManager.getLoggedInUserId(req.session) try { const groups = await ZoteroApiClient.getGroupsForUser(userId) - res.json({ groups }) + 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', - }) + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error(OError.getFullStack(err)) + logger.error({ info }, "failed to get user groups") + return res.status(errStatus).json({ message: err.message }) } } /** - * 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 + * DELETE /user/zotero/unlink * Unlinks the user's Zotero account. */ async function unlink(req, res) { @@ -74,13 +137,18 @@ async function unlink(req, res) { await ZoteroApiClient.unlinkAccount(userId) res.sendStatus(200) } catch (err) { - logger.err({ err, userId }, 'error unlinking Zotero') - res.sendStatus(500) + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error(OError.getFullStack(err)) + logger.error({ info }, "error unlinking Zotero account") + return res.status(errStatus).json({ message: err.message }) } } export default { + oauth, + oauthCallback, + getConnectionStatus, getGroups, - link, - unlink, + unlink } diff --git a/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs b/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs index 72bfdfba0a..8fc3929be5 100644 --- a/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs +++ b/services/web/modules/zotero/app/src/ZoteroLinkedFileAgent.mjs @@ -1,15 +1,17 @@ import logger from '@overleaf/logger' import { callbackify } from '@overleaf/promise-utils' +import { Project } from '../../../../app/src/models/Project.mjs' +import ProjectLocator from '../../../../app/src/Features/Project/ProjectLocator.mjs' import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' +import { + NotFoundError, + TooManyRequestsError, + ServiceNotConfiguredError, + ForbiddenError +} from '../../../../app/src/Features/Errors/Errors.js' import LinkedFilesHandler from '../../../../app/src/Features/LinkedFiles/LinkedFilesHandler.mjs' import LinkedFilesErrors from '../../../../app/src/Features/LinkedFiles/LinkedFilesErrors.mjs' import ZoteroApiClient from './ZoteroApiClient.mjs' -import { ZoteroForbiddenError, ZoteroAccountNotLinkedError } from './ZoteroApiClient.mjs' - -const { - AccessDeniedError, - RemoteServiceError, -} = LinkedFilesErrors /** * Create a linked .bib file from Zotero (either My Library or a Group Library). @@ -19,8 +21,9 @@ const { * provider: 'zotero' * zoteroGroupId?: string * importedAt: Date | string - * importedByUserId?: string - * importedByName?: string + * importedByUserId: string + * importedByName: string + * bibFormat: 'bibtex' || 'biblatex' * } * * - If zoteroGroupId is present, export that group's library. @@ -33,13 +36,14 @@ async function createLinkedFile( parentFolderId, userId ) { - logger.debug( - { projectId, userId, groupId: linkedFileData.zoteroGroupId }, - 'creating Zotero linked file' - ) linkedFileData.importedByUserId = userId - linkedFileData.importedByName = await _getUserName(userId) + linkedFileData.importedByName = await _getUserName(userId) || 'Unknown' + + logger.debug( + { projectId, userId, linkedFileData }, + 'creating Zotero linked file' + ) const bibtex = await _getBibtex(linkedFileData) @@ -65,10 +69,26 @@ async function refreshLinkedFile( userId ) { logger.debug( - { projectId, userId, groupId: linkedFileData.zoteroGroupId }, + { projectId, userId, linkedFileData }, 'refreshing Zotero linked file' ) +// refresh importer's displayed name +// if the importer is the owner, name is not displayed, refresh is not needed +// if the importer is not available, the old name is preserved + const userName = await _getUserName(linkedFileData.importedByUserId) + if (userName && linkedFileData.importedByUserId != userId) { + linkedFileData.importedByName = userName + const { element, path } = await ProjectLocator.promises.findElement({ + project_id: projectId, + element_id: parentFolderId, + type: 'folders' + }) + const fileIndex = element.fileRefs.findIndex(file => file.name === name) + const updatePath = `${path.mongo}.fileRefs.${fileIndex}.linkedFileData.importedByName` + await Project.updateOne({ _id: projectId }, { $set: { [updatePath]: userName } }) + } + const bibtex = await _getBibtex(linkedFileData) const file = await LinkedFilesHandler.promises.importContent( @@ -85,22 +105,27 @@ async function refreshLinkedFile( async function _getBibtex(linkedFileData) { const userId = linkedFileData.importedByUserId try { - if (linkedFileData.zoteroGroupId) { - return await ZoteroApiClient.getGroupLibraryBibtex( - userId, - linkedFileData.zoteroGroupId - ) - } else { - return await ZoteroApiClient.getUserLibraryBibtex(userId) - } + return await ZoteroApiClient.getLibraryBibtex( + userId, + linkedFileData.zoteroGroupId, // == null for main library + linkedFileData.bibFormat || 'bibtex' + ) } catch (err) { - if (err instanceof ZoteroForbiddenError) { - throw new AccessDeniedError('Zotero access denied').withCause(err) + + if (err instanceof ForbiddenError) { + logger.debug({ linkedFileData, err }, 'Zotero access denied') + throw new LinkedFilesErrors.AccessDeniedError('Zotero access denied').withCause(err) } - if (err instanceof ZoteroAccountNotLinkedError) { - throw new AccessDeniedError('Zotero account not linked').withCause(err) + if (err instanceof ServiceNotConfiguredError) { + logger.debug({ userId: linkedFileData.importedByUserId, err }, 'Zotero account not linked') + throw new LinkedFilesErrors.AccessDeniedError('Zotero account not linked').withCause(err) } - throw new RemoteServiceError('Zotero API error').withCause(err) + if (err instanceof NotFoundError) { + logger.debug({ group: linkedFileData.zoteroGroupId, err }, 'Zotero group is not found') + throw new LinkedFilesErrors.SourceFileNotFoundError('Zotero group is not found').withCause(err) + } + logger.error({ linkedFileData, err }, 'failed to retrieve bib file from Zotero') + throw new LinkedFilesErrors.RemoteServiceError('Error retrieving bib file from Zotero').withCause(err) } } @@ -115,6 +140,7 @@ function _sanitizeData(data) { importedByUserId: data.importedByUserId, }), importedByName: data.importedByName || 'Unknown', + bibFormat: (data.bibFormat === 'biblatex') ? 'biblatex' : 'bibtex' } } @@ -126,12 +152,12 @@ async function _getUserName(userId) { catch (err) { logger.error({ userId, err }, 'failed to get user info') } - if (!user) return 'Unknown' + if (!user) return null const { email, first_name, last_name } = user const name = (first_name || last_name) ? [first_name, last_name].filter(n => n != null).join(' ') : email - return name || 'Unknown' + return name } export default { diff --git a/services/web/modules/zotero/app/src/ZoteroOAuth.mjs b/services/web/modules/zotero/app/src/ZoteroOAuth.mjs new file mode 100644 index 0000000000..3be131e960 --- /dev/null +++ b/services/web/modules/zotero/app/src/ZoteroOAuth.mjs @@ -0,0 +1,125 @@ +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import OError from '@overleaf/o-error' +import { fetchString } from '@overleaf/fetch-utils' +import OAuth from 'oauth-1.0a' +import crypto from 'crypto' + +const ZOTERO_OAUTH_URL = 'https://www.zotero.org/oauth' + +function hashFunctionSha1(baseString, key) { + return crypto.createHmac('sha1', key) + .update(baseString) + .digest('base64') +} + +function createOAuthClient() { + return OAuth({ + consumer: { + key: Settings.zotero.clientKey, + secret: Settings.zotero.clientSecret, + }, + signature_method: 'HMAC-SHA1', + hash_function: hashFunctionSha1, + }) +} + +function buildAuthorizationHeader(requestData, tokenData) { + const oauth = createOAuthClient() + const authData = oauth.authorize(requestData, tokenData) + + return oauth.toHeader(authData).Authorization +} + +function parseOAuthResponse(body) { + return Object.fromEntries(new URLSearchParams(body).entries()) +} + +function getAuthorizationUrl(oauthToken) { + const params = new URLSearchParams({ + oauth_token: oauthToken, + library_access: '1', + all_groups: 'read', + }) + + return `${ZOTERO_OAUTH_URL}/authorize?${params}` +} +/* +function getAuthorizationUrl(oauthToken) { + return `${ZOTERO_OAUTH_URL}/authorize?oauth_token=${oauthToken}` +} +*/ + +async function getRequestToken() { + const url = `${ZOTERO_OAUTH_URL}/request` + + try { + const body = await fetchString(url, { + method: 'POST', + headers: { + Authorization: buildAuthorizationHeader({ + url, + method: 'POST', + data: { + oauth_callback: Settings.zotero.callbackURL, + }, + }), + }, + }) + + return parseOAuthResponse(body) + } catch (err) { + throw OError.tag(err, 'error obtaining Zotero request token') + } +} + +async function exchangeRequestTokenForAccessToken( + oauthToken, + oauthTokenSecret, + oauthVerifier, +) { + const url = `${ZOTERO_OAUTH_URL}/access` + + try { + const body = await fetchString(url, { + method: 'POST', + headers: { + Authorization: buildAuthorizationHeader( + { + url, + method: 'POST', + data: { + oauth_verifier: oauthVerifier, + }, + }, + { + key: oauthToken, + secret: oauthTokenSecret, + } + ), + }, + }) + + const data = parseOAuthResponse(body) + + return { + accessToken: data.oauth_token, + apiKeySecret: data.oauth_token_secret, + zoteroUserId: data.userID, + username: data.username, + } + } catch (err) { + logger.err( + { err, oauthToken }, + 'error obtaining Zotero access token' + ) + + throw OError.tag(err, 'error obtaining Zotero access token') + } +} + +export default { + getAuthorizationUrl, + getRequestToken, + exchangeRequestTokenForAccessToken, +} diff --git a/services/web/modules/zotero/app/src/ZoteroRouter.mjs b/services/web/modules/zotero/app/src/ZoteroRouter.mjs index 882a602555..f09f54b2d8 100644 --- a/services/web/modules/zotero/app/src/ZoteroRouter.mjs +++ b/services/web/modules/zotero/app/src/ZoteroRouter.mjs @@ -5,23 +5,36 @@ export default { apply(webRouter) { // Get Zotero groups for the create-file modal webRouter.get( - '/zotero/groups', + '/user/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', + webRouter.delete( + '/user/zotero', AuthenticationController.requireLogin(), ZoteroController.unlink + ) + webRouter.get( + '/user/zotero/status', + AuthenticationController.requireLogin(), + ZoteroController.getConnectionStatus ) + + webRouter.get( + '/user/zotero/oauth', + AuthenticationController.requireLogin(), + ZoteroController.oauth + ) + // callback for Zotero OAuth flow + webRouter.get( + '/user/zotero/oauth/callback', + AuthenticationController.requireLogin(), + ZoteroController.oauthCallback + ) + + + }, } diff --git a/services/web/modules/zotero/frontend/js/components/file-tree-import-from-zotero.tsx b/services/web/modules/zotero/frontend/js/components/file-tree-import-from-zotero.tsx new file mode 100644 index 0000000000..37ce78f8d2 --- /dev/null +++ b/services/web/modules/zotero/frontend/js/components/file-tree-import-from-zotero.tsx @@ -0,0 +1,112 @@ +import { useTranslation } from 'react-i18next' +import { FormEventHandler, useEffect, useState } from 'react' +import * as eventTracking from '@/infrastructure/event-tracking' +import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable' +import { useFileTreeCreateForm } from '@/features/file-tree/contexts/file-tree-create-form' +import { useFileTreeCreateName } from '@/features/file-tree/contexts/file-tree-create-name' +import FileTreeCreateNameInput from '@/features/file-tree/components/file-tree-create/file-tree-create-name-input' +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 OLFormSelect from '@/shared/components/ol/ol-form-select' +import OLNotification from '@/shared/components/ol/ol-notification' + +type ZoteroGroup = { + id: string + name: string +} + +type FileTreeImportFromZoteroProps = { groups: ZoteroGroup[] } + +export default function FileTreeImportFromZotero({ groups }: FileTreeImportFromZoteroProps) { + const { t } = useTranslation() + const { name, setName } = useFileTreeCreateName() + const { setValid } = useFileTreeCreateForm() + const { finishCreatingLinkedFile, error, inFlight } = useFileTreeActionable() + const [selectedGroupId, setSelectedGroupId] = useState('') + const [selectedFormat, setSelectedFormat] = useState('bibtex') + + useEffect(() => { + setValid(!!name) + }, [name]) + + const handleSubmit: FormEventHandler = event => { + event.preventDefault() + eventTracking.sendMB('new-file-created', { + method: 'zotero', + extension: name.split('.').length > 1 ? name.split('.').pop() : '', + }) + + finishCreatingLinkedFile({ + name, + provider: 'zotero', + data: { zoteroGroupId: selectedGroupId, bibFormat: selectedFormat } + }) + } + + return ( + <> +

{t('import_a_bibtex_file_from_your_provider_account', { provider: 'Zotero' })}

+
+ + {t('library')} + ) => + setSelectedGroupId(e.target.value) + } + > + + {groups?.map(g => ( + + ))} + + + + + + + {t('format')} + ) => + setSelectedFormat(e.target.value) + } + > + + + + + + {inFlight && ( +
+ + {t('importing') + '…'} +
+ )} + + {error && } + + + + ) +} 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 index 1bc362e1d5..5865546bf7 100644 --- 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 @@ -5,15 +5,17 @@ import { LinkedFileIcon } from '@/features/file-view/components/file-view-icons' import { hasProvider } from '@/features/file-view/types/binary-file' import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file' +/** + * Shows "Imported from Zotero at " in the owner's file view header or + * "Imported from Zotero at by " in the collaborator's file view header + * when viewing a Zotero-linked .bib file. + * Registered via overleafModuleImports.tprFileViewInfo. + */ + type TPRFileViewInfoProps = { file: LinkedFile } -/** - * 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() 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 index 103b605e32..ee71c9d4d0 100644 --- 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 @@ -1,4 +1,3 @@ -import type { BinaryFile } from '@/features/file-view/types/binary-file' import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file' type TPRFileViewInfoProps = { 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 index 55363997bb..ff05f6e91c 100644 --- 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 @@ -20,17 +20,13 @@ export function TPRFileViewRefreshButton({ refreshing, }: TPRFileViewRefreshButtonProps) { const { t } = useTranslation() - const importedByUserId = (file.linkedFileData as any)?.importedByUserId const zoteroIsProvider = hasProvider(file, 'zotero') - const disabled = (zoteroIsProvider && !importedByUserId) ? true : false return ( refreshFile(zoteroIsProvider ? true : null)} - disabled={refreshing || disabled} + onClick={() => refreshFile(zoteroIsProvider)} isLoading={refreshing} - loadingLabel={t('refreshing')} > {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 index ca95ce8d90..8df80ce1ce 100644 --- 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 @@ -3,15 +3,16 @@ import OLNotification from '@/shared/components/ol/ol-notification' import type { LinkedFile, LinkedFileData } from '@/features/file-view/types/binary-file' import { hasProvider } from '@/features/file-view/types/binary-file' +/** + * Zotero-specific error messages when refreshing a linked file fails. + * Registered via overleafModuleImports.tprFileViewRefreshError. + */ + type TPRFileViewRefreshErrorProps = { file: LinkedFile refreshError: string } -/** - * Zotero-specific error messages when refreshing a linked file fails. - * Registered via overleafModuleImports.tprFileViewRefreshError. - */ export function TPRFileViewRefreshError({ file, refreshError, 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 index ba311f1de3..602b0af7ac 100644 --- a/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx +++ b/services/web/modules/zotero/frontend/js/components/zotero-create-file.tsx @@ -1,167 +1,119 @@ import { useTranslation } from 'react-i18next' -import { FormEventHandler, useEffect, useState, useCallback } from 'react' +import { useEffect, useCallback } from 'react' +import useAsync from '@/shared/hooks/use-async' 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 { debugConsole } from '@/utils/debugging' +import OLButton from '@/shared/components/ol/ol-button' import OLNotification from '@/shared/components/ol/ol-notification' - -type ZoteroGroup = { - id: string - name: string -} +import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable' +import FileTreeModalCreateFileMode from '@/features/file-tree/components/file-tree-create/file-tree-modal-create-file-mode' +import FileTreeCreateNameProvider from '@/features/file-tree/contexts/file-tree-create-name' +import FileTreeImportFromZotero from './file-tree-import-from-zotero' export function CreateFileMode() { - const { refProviders } = useFileTreeMainContext() - const isLinked = (refProviders as Record)?.zotero - - if (!isLinked) { - return null - } + const { t } = useTranslation() return ( ) } export function CreateFilePane() { const { newFileCreateMode } = useFileTreeActionable() - - if (newFileCreateMode !== 'zotero') { - return null - } - - return -} - -function ZoteroCreateFilePane() { const { t } = useTranslation() - const { setValid } = useFileTreeCreateForm() - const { finishCreatingLinkedFile, error, inFlight } = useFileTreeActionable() + const isZoteroMode = newFileCreateMode === 'zotero' - const [groups, setGroups] = useState([]) - const [selectedGroupId, setSelectedGroupId] = useState('') - const [name, setName] = useState('zotero.bib') - const [loadingGroups, setLoadingGroups] = useState(true) - const [groupsError, setGroupsError] = useState('') + const { + runAsync, + data: groups, + isSuccess: isGroupsLoaded, + isError: isGroupsError, + isLoading: isGroupsLoading, + } = useAsync() + + const loadGroups = useCallback(() => { + return runAsync(getJSON('/user/zotero/groups')) + .catch(err => { + debugConsole.error(err?.data?.message || err?.message || err) + }) + }, [runAsync]) useEffect(() => { - setValid(!!name && !loadingGroups) - }, [setValid, name, loadingGroups]) + if (!isZoteroMode) return + + loadGroups() + }, [loadGroups, isZoteroMode]) useEffect(() => { - let cancelled = false + if (!isZoteroMode || !isGroupsLoaded || groups) return - setLoadingGroups(true) - setGroupsError('') - - getJSON('/zotero/groups') - .then((data: { groups: ZoteroGroup[] }) => { - if (cancelled) return - - setGroups(data.groups || []) - setLoadingGroups(false) - }) - .catch(() => { - if (cancelled) return - - setGroupsError(t('zotero_groups_loading_error')) - setLoadingGroups(false) - }) - - return () => { - cancelled = true + const channel = new BroadcastChannel('zotero') + channel.onmessage = event => { + if (event.data?.type === 'zotero-linked') { + loadGroups() + } } - }, [t]) - const handleSubmit: FormEventHandler = useCallback( - event => { - event.preventDefault() + return () => channel.close() + }, [isZoteroMode, isGroupsLoaded, groups, loadGroups]) - if (!name || loadingGroups || inFlight) { - return - } + if (!isZoteroMode) return null - const data: Record = {} - - if (selectedGroupId) { - data.zoteroGroupId = selectedGroupId - } - - finishCreatingLinkedFile({ - name, - provider: 'zotero', - data, - }) - }, - [ - name, - loadingGroups, - inFlight, - selectedGroupId, - finishCreatingLinkedFile, - ] - ) - - return ( -
- {groupsError && ( - - )} - - - {t('file_name')} - ) => - setName(e.target.value) - } - /> - - - - Library - {loadingGroups ? ( -

{t('loading')}...

- ) : ( - ) => - setSelectedGroupId(e.target.value) - } - > - - {groups.map(g => ( - - ))} - - )} -
- - {error && } - - ) + if (isGroupsLoading) { + return ( + <> +
+
+ + {t('loading') + '…'} +
+ + ) + } else if (isGroupsLoaded) { + if (groups) { + return ( + + + + ) + } else { + return ( +
+

{t('zotero_sync_description')}

+

+ { + window.open( + '/user/zotero/oauth?popup=1', + '_blank', + 'width=600,height=700' + ) + }} + > + {t('link_to_zotero')} + +

+
+ ) + } + } else if (isGroupsError) { + return ( + + ) + } } diff --git a/services/web/modules/zotero/frontend/js/components/zotero-integration-card.tsx b/services/web/modules/zotero/frontend/js/components/zotero-integration-card.tsx new file mode 100644 index 0000000000..961d2462d6 --- /dev/null +++ b/services/web/modules/zotero/frontend/js/components/zotero-integration-card.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import useAsync from '@/shared/hooks/use-async' +import { getJSON } from '@/infrastructure/fetch-json' +import { debugConsole } from '@/utils/debugging' +import ZoteroLogo from '@/shared/svgs/zotero-logo' +import IntegrationCard from '@/features/integrations-panel/integration-card' +import ZoteroLinkInfoModal from './zotero-link-info-modal' + +const ZoteroLinkCard = () => { + const { t } = useTranslation() + + const [showModal, setShowModal] = useState(false) + + const { + isError, + runAsync, + data: isConnected + } = useAsync() + + const handleClick = () => { + runAsync(getJSON('/user/zotero/status')) + .then(isConnected => { + if (isConnected) { + setShowModal(true) + } else { + window.open( + '/user/zotero/oauth?popup=1', + '_blank', + 'width=600,height=700' + ) + } + }) + .catch(err => { + debugConsole.error( + err?.data?.message || err?.message || err + ) + setShowModal(true) + }) + } + + return ( + <> + } + showPaywallBadge={false} + onClick={handleClick} + /> + + setShowModal(false)} + isError={isError} + /> + + ) +} + +export default ZoteroLinkCard diff --git a/services/web/modules/zotero/frontend/js/components/zotero-link-info-modal.tsx b/services/web/modules/zotero/frontend/js/components/zotero-link-info-modal.tsx new file mode 100644 index 0000000000..6ed8b5e212 --- /dev/null +++ b/services/web/modules/zotero/frontend/js/components/zotero-link-info-modal.tsx @@ -0,0 +1,109 @@ +import { useTranslation, Trans } from 'react-i18next' +import useAsync from '@/shared/hooks/use-async' +import { deleteJSON } from '@/infrastructure/fetch-json' +import { debugConsole } from '@/utils/debugging' +import { useState, useEffect, useCallback } from 'react' +import { + OLModal, + OLModalHeader, + OLModalTitle, + OLModalBody, + OLModalFooter, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' +import OLNotification from '@/shared/components/ol/ol-notification' + +import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types' + +type ZoteroLinkInfoModalModalProps = { + show: boolean + isError: any + handleHide: () => void +} + +const ZoteroLinkInfoModal = ({ show, isError, handleHide }: ZoteroLinkInfoModalProps) => { + const { t } = useTranslation() + const [showUnlinkInfo, setShowUnlinkInfo] = useState(false) + + const { + isLoading: isUnlinking, + isError: isErrorUnlink, + runAsync: runAsyncUnlink, + } = useAsync() + + const handleUnlink = () => { + runAsyncUnlink(deleteJSON('/user/zotero')) + .catch(err => debugConsole.error(err?.data?.message || err?.message || err)) + handleHide() + } + + useEffect(() => { + if (!show) return + setShowUnlinkInfo(false) + }, [show]) + + return ( + <> + + + {t('zotero_integration')} + + + + {showUnlinkInfo ? ( + + ) : isError ? ( + + ) : ( + ]} + /> + } + /> + )} + + + + {showUnlinkInfo ? ( + + {t('confirm')} + + ) : ( + setShowUnlinkInfo(true)} + > + {t('unlink')} + + )} + + {t('close')} + + + + + ) +} + +export default ZoteroLinkInfoModal diff --git a/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx b/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx index 2e28cbc85c..9a57acbfc2 100644 --- a/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx +++ b/services/web/modules/zotero/frontend/js/components/zotero-widget.tsx @@ -1,10 +1,18 @@ +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useCallback, useState } from 'react' -import { postJSON } from '@/infrastructure/fetch-json' import getMeta from '@/utils/meta' +import { getJSON, deleteJSON } from '@/infrastructure/fetch-json' +import useAsync from '@/shared/hooks/use-async' +import { debugConsole } from '@/utils/debugging' import OLButton from '@/shared/components/ol/ol-button' +import { + OLModal, + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' import OLNotification from '@/shared/components/ol/ol-notification' -import OLFormControl from '@/shared/components/ol/ol-form-control' import ZoteroLogo from '@/shared/svgs/zotero-logo' /** @@ -16,120 +24,159 @@ import ZoteroLogo from '@/shared/svgs/zotero-logo' * * Registered via overleafModuleImports.referenceLinkingWidgets. */ -export default function ZoteroWidget() { +export const ZoteroWidget = 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 { appName } = getMeta('ol-ExposedSettings') - 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 { + isLoading: isCheckingConn, + isError: isErrorConnCheck, + runAsync: runAsyncConnCheck, + data: isConnected, + setData: setConnState, + } = useAsync() - 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]) + const { + isLoading: isUnlinking, + isError: isErrorUnlink, + runAsync: runAsyncUnlink, + } = useAsync() + + const [showUnlinkModal, setShowUnlinkModal] = useState(false) + + const handleConnCheck = useCallback(() => { + runAsyncConnCheck(getJSON('/user/zotero/status')) + .catch(err => debugConsole.error(err?.data?.message || err?.message || err)) + }, [runAsyncConnCheck]) + + useEffect(() => { + handleConnCheck() + }, [handleConnCheck]) + + const handleUnlink = useCallback(() => { + runAsyncUnlink(deleteJSON('/user/zotero')) + .then(() => setConnState(false)) + .catch(err => debugConsole.error(err?.data?.message || err?.message || err)) + .finally(() => setShowUnlinkModal(false)) + }, [runAsyncUnlink]) + + if (isCheckingConn) { + return ( +
+
+ +
+ +
+
+

GitHub

+
+ +

+ {t('loading')}… +

+
+
+ ) + } 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('zotero_sync_description', { appName })} +

+ + {isErrorConnCheck && ( + + )} + + {isErrorUnlink && ( + + )} +
+ +
+ {isConnected ? ( setShowUnlinkModal(true)} + disabled={isUnlinking} > - {t('link_to_zotero')} + {isUnlinking ? t('unlinking') : t('unlink')} - - )} + ) : isErrorConnCheck ? ( + + {t('reconnect')} + + ) : ( + + {t('link')} + + )} +
-
- {isLinked && ( + + setShowUnlinkModal(false)} + backdrop="static" + > + + + {t('unlink_reference', { + provider: 'Zotero', + })} + + + + +

+ {t('unlink_warning_reference', { + provider: 'Zotero', + })} +

+
+ + + setShowUnlinkModal(false)} + > + {t('cancel')} + + - {t('unlink')} + {isUnlinking ? t('unlinking') : t('unlink')} - )} -
-
+ + + ) } diff --git a/services/web/modules/zotero/index.mjs b/services/web/modules/zotero/index.mjs index db8a035d17..409d083d5a 100644 --- a/services/web/modules/zotero/index.mjs +++ b/services/web/modules/zotero/index.mjs @@ -8,6 +8,13 @@ if (Settings.enabledLinkedFileTypes?.includes('zotero')) { './app/src/ZoteroLinkedFileAgent.mjs' ) + const siteUrl = Settings.siteUrl.replace(/\/+$/, '') || 'http://localhost' + Settings.zotero = { + clientKey: process.env.ZOTERO_CLIENT_KEY, + clientSecret: process.env.ZOTERO_CLIENT_SECRET, + callbackURL: `${siteUrl}/user/zotero/oauth/callback`, + }, + ZoteroModule = { router: ZoteroRouter, linkedFileAgents: { diff --git a/services/web/package.json b/services/web/package.json index 0a5e8e319a..249d3f666a 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -163,6 +163,7 @@ "nocache": "^2.1.0", "node-fetch": "^2.7.0", "nodemailer": "^8.0.5", + "oauth-1.0a": "^2.2.6", "on-headers": "^1.0.2", "otplib": "^12.0.1", "overleaf-editor-core": "workspace:*",