From de2744f834f17427ed9dc614daa497ae1f222b51 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Sun, 21 Jun 2026 23:50:16 +0200 Subject: [PATCH] Github Sync: initial commit --- services/web/config/settings.defaults.js | 26 +- .../web/frontend/extracted-translations.json | 7 + .../context/editor-manager-context.tsx | 2 +- .../web/frontend/stylesheets/modules/all.scss | 1 + services/web/locales/de.json | 9 +- services/web/locales/en.json | 11 +- services/web/locales/ru.json | 14 +- .../app/models/githubSyncProjectStates.mjs | 24 + .../app/models/githubSyncUserCredentials.mjs | 17 + .../github-sync/app/src/GitHubApiClient.mjs | 495 +++++++++++ .../app/src/GitHubSyncController.mjs | 239 ++++++ .../github-sync/app/src/GitHubSyncHandler.mjs | 241 ++++++ .../github-sync/app/src/GitHubSyncRouter.mjs | 100 +++ .../modules/github-sync/app/src/GitMerge.mjs | 783 ++++++++++++++++++ .../github-sync/app/src/GitSyncErrors.mjs | 26 + .../github-sync/app/src/HistoryManager.mjs | 97 +++ .../github-sync/app/src/SyncStateManager.mjs | 32 + .../github-sync/app/src/TokenManager.mjs | 72 ++ .../js/components/github-integration-card.tsx | 41 + .../js/components/github-sync-widget.tsx | 174 ++++ .../js/components/import-from-github-menu.tsx | 12 + .../import-from-github-modal-wrapper.tsx | 218 +++++ .../modals/git-sync-cannot-export-modal.tsx | 47 ++ .../modals/git-sync-confirm-unlink-modal.tsx | 101 +++ .../modals/git-sync-conflict-modal.tsx | 57 ++ .../modals/git-sync-export-modal.tsx | 229 +++++ .../modals/git-sync-loading-modal.tsx | 54 ++ .../modals/git-sync-merge-modal.tsx | 119 +++ .../modals/git-sync-merge-overview-modal.tsx | 238 ++++++ .../js/components/modals/git-sync-modal.tsx | 226 +++++ .../modals/git-sync-need-auth-modal.tsx | 43 + .../modals/git-sync-need-permission-modal.tsx | 57 ++ .../git-sync-unlink-unavailable-modal.tsx | 85 ++ .../frontend/js/types/git-sync-types.ts | 19 + .../frontend/styles/github-sync.scss | 8 + services/web/modules/github-sync/index.mjs | 55 ++ 36 files changed, 3969 insertions(+), 10 deletions(-) create mode 100644 services/web/modules/github-sync/app/models/githubSyncProjectStates.mjs create mode 100644 services/web/modules/github-sync/app/models/githubSyncUserCredentials.mjs create mode 100644 services/web/modules/github-sync/app/src/GitHubApiClient.mjs create mode 100644 services/web/modules/github-sync/app/src/GitHubSyncController.mjs create mode 100644 services/web/modules/github-sync/app/src/GitHubSyncHandler.mjs create mode 100644 services/web/modules/github-sync/app/src/GitHubSyncRouter.mjs create mode 100644 services/web/modules/github-sync/app/src/GitMerge.mjs create mode 100644 services/web/modules/github-sync/app/src/GitSyncErrors.mjs create mode 100644 services/web/modules/github-sync/app/src/HistoryManager.mjs create mode 100644 services/web/modules/github-sync/app/src/SyncStateManager.mjs create mode 100644 services/web/modules/github-sync/app/src/TokenManager.mjs create mode 100644 services/web/modules/github-sync/frontend/js/components/github-integration-card.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/github-sync-widget.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/import-from-github-menu.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/import-from-github-modal-wrapper.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/modals/git-sync-cannot-export-modal.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/modals/git-sync-confirm-unlink-modal.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/modals/git-sync-conflict-modal.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/modals/git-sync-export-modal.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/modals/git-sync-loading-modal.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/modals/git-sync-merge-modal.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/modals/git-sync-merge-overview-modal.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/modals/git-sync-modal.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/modals/git-sync-need-auth-modal.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/modals/git-sync-need-permission-modal.tsx create mode 100644 services/web/modules/github-sync/frontend/js/components/modals/git-sync-unlink-unavailable-modal.tsx create mode 100644 services/web/modules/github-sync/frontend/js/types/git-sync-types.ts create mode 100644 services/web/modules/github-sync/frontend/styles/github-sync.scss create mode 100644 services/web/modules/github-sync/index.mjs diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 07820776e5..bdb3f6985b 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1062,15 +1062,30 @@ module.exports = { pythonRunner: [], langFeedbackLinkingWidgets: [], labsExperiments: [], - integrationLinkingWidgets: [], + integrationLinkingWidgets: [ + Path.resolve( + __dirname, + '../modules/github-sync/frontend/js/components/github-sync-widget.tsx' + ), + ], referenceLinkingWidgets: [ Path.resolve( __dirname, '../modules/zotero/frontend/js/components/zotero-widget' ), ], - importProjectFromGithubModalWrapper: [], - importProjectFromGithubMenu: [], + importProjectFromGithubModalWrapper: [ + Path.resolve( + __dirname, + '../modules/github-sync/frontend/js/components/import-from-github-modal-wrapper.tsx' + ), + ], + importProjectFromGithubMenu: [ + Path.resolve( + __dirname, + '../modules/github-sync/frontend/js/components/import-from-github-menu.tsx' + ), + ], editorLeftMenuSync: [ Path.resolve( __dirname, @@ -1140,6 +1155,10 @@ module.exports = { ), ], integrationPanelComponents: [ + Path.resolve( + __dirname, + '../modules/github-sync/frontend/js/components/github-integration-card.tsx' + ), Path.resolve( __dirname, '../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx' @@ -1176,6 +1195,7 @@ module.exports = { 'admin-tools', // import after authentication 'template-gallery', 'git-bridge', + 'github-sync', 'zotero', ], viewIncludes: {}, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 31a7d4814a..be6e492e0c 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -204,6 +204,8 @@ "are_you_sure_you_want_to_cancel": "", "are_you_sure_you_want_to_cancel_add_on": "", "as_email": "", + "ask_proj_owner_to_add_you_as_github_collaborator": "", + "ask_proj_owner_to_export_to_github": "", "ask_proj_owner_to_unlink_from_current_github": "", "ask_proj_owner_to_upgrade_for_full_history": "", "ask_repo_owner_to_reconnect": "", @@ -423,6 +425,7 @@ "contacting_the_sales_team": "", "continue": "", "continue_github_merge": "", + "continue_github_merge": "", "continue_to": "", "continue_to_checkout": "", "continue_using_free_features": "", @@ -893,6 +896,9 @@ "github_timeout_error": "", "github_too_many_files_error": "", "github_validation_check": "", + "github_validation_check_auth": "", + "github_validation_name_exists": "", + "github_validation_name_invalid": "", "github_workflow_authorize": "", "github_workflow_files_delete_github_repo": "", "github_workflow_files_error": "", @@ -1483,6 +1489,7 @@ "optional": "", "or": "", "or_enable_clipboard_access_in_your_browser_settings": "", + "organization": "", "organization_name": "", "organize_tags": "", "other": "", diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx index 6c550a6a91..d697db73a2 100644 --- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx @@ -251,7 +251,7 @@ export const EditorManagerProvider: FC = ({ } if ( update.meta.type === 'external' && - update.meta.source === 'git-bridge' + (update.meta.source === 'git-bridge' || update.meta.source === 'github') ) { return } diff --git a/services/web/frontend/stylesheets/modules/all.scss b/services/web/frontend/stylesheets/modules/all.scss index 3245355191..2bb2d12957 100644 --- a/services/web/frontend/stylesheets/modules/all.scss +++ b/services/web/frontend/stylesheets/modules/all.scss @@ -15,4 +15,5 @@ @import 'admin-tools/manage-projects-page'; @import 'admin-tools/manage-users-page'; @import 'admin-tools/pagination-dark'; +@import 'github-sync'; @import '../../../modules/reference-picker/frontend/styles/reference-picker'; diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 709f4538d3..c1b088d38c 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -198,6 +198,8 @@ "as_a_member_of_sso_required": "Als Mitglied von __institutionName__ musst du dich über dein institutionelles Portal bei __appName__ anmelden.", "as_email": "als __email__", "ascending": "Aufsteigend", + "ask_proj_owner_to_add_you_as_github_collaborator": "Du hast keine Berechtigung, in das verknüpfte Repository (<0>__repoFullName__) zu pushen. Bitte den Projektinhaber (<1>__projectOwnerEmail__), dich als Kollaborator zu diesem Repository hinzuzufügen.", + "ask_proj_owner_to_export_to_github": "Bitte den Projektinhaber (<0>__projectOwnerEmail__), das Projekt in ein Git-Repository zu exportieren und dich als Kollaborator hinzuzufügen.", "ask_proj_owner_to_unlink_from_current_github": "Bitte den Projektbesitzer (<0>__projectOwnerEmail__), das Projekt vom aktuellen GitHub-Repository zu entkoppeln und eine Verbindung zu einem anderen herzustellen.", "ask_proj_owner_to_upgrade_for_full_history": "Bitte den Projektbesitzer um ein Abonnement-Upgrade, um auf den vollständigen Verlauf dieses Projekts zugreifen zu können.", "ask_repo_owner_to_reconnect": "Bitte den Besitzer des GitHub-Repositorys (<0>__repoOwnerEmail__), das Projekt neu zu verbinden.", @@ -671,10 +673,12 @@ "github_integration_lowercase": "Git- und GitHub-Integration", "github_is_premium": "GitHub-Sync ist eine Premiumfunktion", "github_large_files_error": "Zusammenführung fehlgeschlagen: Dein GitHub-Repository enthält Dateien mit einer Dateigröße von mehr als 50 MB ", + "github_manual_merge_user_prompt": "Bitte führe den <0>__sharelatex_branch__ mit dem Standard-Branch in Git zusammen. Klicke unten um fortzufahren, nachdem du manuell zusammengeführt hast.", + "github_merge_failed_error": "Deine Änderungen in __appName__ und GitHub konnten nicht automatisch zusammengeführt werden.", "github_no_master_branch_error": "Dieses Repository kann nicht importiert werden, da ihm ein Standard-Branch fehlt. Stell sicher, dass das Projekt einen Standard-Branch hat", "github_private_description": "Du wählst, wer dieses Repository sehen und etwas übergeben kann.", "github_public_description": "Jeder kann dieses Repository sehen. Du entscheidest wer committen darf.", - "github_repository_diverged": "Der Standard-Branch des verknüpften Repositorys wurde forciert gepusht. Das Pullen von GitHub-Änderungen nach einem forciertem Push kann dazu führen, dass Overleaf und GitHub nicht mehr synchron sind. Möglicherweise musst du Änderungen nach dem Pullen erneut Pushen um wieder synchron zu sein", + "github_repository_diverged": "Der Standard-Branch des verknüpften Repositorys wurde forciert gepusht. Die Liste der letzten Commits unten kann aufgrund einer umgeschriebenen Historie seit der letzten Synchronisierung unvollständig sein.", "github_successfully_linked_description": "Danke, wir haben dein GitHub-Nutzerkonto erfolgreich mit __appName__ verknüpft. Du kannst die __appName__-Projekte jetzt in GitHub exportieren oder Projekte aus deinen GitHub-Repositories importieren.", "github_symlink_error": "Dein GitHub-Repository enthält Dateien mit symbolischen Links, was derzeit von Overleaf nicht unterstützt wird. Entferne diese und versuche es erneut.", "github_sync_description": "Mit GitHub-Synchronisierung kannst du deine __appName__-Projekte mit GitHub-Repositories verlinken. Erstelle neue Commits aus __appName__ und führe sie mit Commits in GitHub zusammen.", @@ -1321,6 +1325,7 @@ "project_has_too_many_files_limit": "Dieses Projekt hat das Limit von __limit__ Dateien erreicht", "project_history": "Projektverlauf", "project_last_published_at": "Dein Projekt wurde zuletzt veröffentlicht am", + "project_linked_to": "Dieses Projekt ist verlinkt mit", "project_name": "Projektname", "project_not_linked_to_github": "Dieses Projekt ist nicht mit einem GitHub Repository verlinkt. Du kannst ein neues Repository in GitHub erstellen:", "project_notifications": "Projektbenachrichtigungen", @@ -1596,7 +1601,7 @@ "sync_dropbox_github": "Mit Dropbox und GitHub synchronisieren", "sync_project_to_github_explanation": "Alle Änderungen die du in __appName__ vornimmst werden in GitHub festgelegt und mit allen Updates in GitHub zusammengeführt.", "sync_to_dropbox": "Synchronisierung mit Dropbox", - "sync_to_github": "Mit GitHub synchronisieren", + "sync_with_github": "Mit GitHub synchronisieren", "synctex_failed": "Die entsprechende Quelldatei konnte nicht gefunden werden", "syntax_validation": "Syntaxüberprüfung", "tab_connecting": "Verbindung mit dem Editor wird hergestellt", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index bea63c1556..63730c131e 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -252,7 +252,9 @@ "as_a_member_of_sso_required": "As a member of __institutionName__, you must log in to __appName__ through your institution.", "as_email": "as __email__", "ascending": "Ascending", - "ask_proj_owner_to_unlink_from_current_github": "Ask the owner of the project (<0>__projectOwnerEmail__) to unlink the project from the current GitHub repository and create a connection to a different repository.", + "ask_proj_owner_to_add_you_as_github_collaborator": "You don’t have permission to push to the linked repository <0>__repoFullName__. Ask the owner of the project (<1>__projectOwnerEmail__) to add you as a collaborator to this repository.", + "ask_proj_owner_to_export_to_github": "Ask the owner of the project (<0>__projectOwnerEmail__) to export the project to a Git repository and add you as a collaborator.", + "ask_proj_owner_to_unlink_from_current_github": "Ask the owner of the project (<0>__projectOwnerEmail__) to unlink the project from the current git repository and create a connection to a different repository.", "ask_proj_owner_to_upgrade_for_full_history": "Please ask the project owner to upgrade to access this project’s full history.", "ask_repo_owner_to_reconnect": "Ask the GitHub repository owner (<0>__repoOwnerEmail__) to reconnect the project.", "ask_repo_owner_to_renew_overleaf_subscription": "Ask the GitHub repository owner (<0>__repoOwnerEmail__) to renew their __appName__ subscription and reconnect the project.", @@ -1110,7 +1112,7 @@ "github_project_too_large_error": "This project is too large to sync with GitHub. There’s a limit on the total size of a project’s editable text files. Try removing or shrinking large text files, then sync again.", "github_public_description": "Anyone can see this repository. You choose who can commit.", "github_repo": "GitHub repo", - "github_repository_diverged": "The default branch of the linked repository has been force-pushed. Pulling GitHub changes after a force push can cause Overleaf and GitHub to get out of sync. You might need to push changes after pulling to get back in sync.", + "github_repository_diverged": "The default branch of the linked repository has been force-pushed. The list of recent commits below may be incomplete due to rewritten history since the last sync.", "github_successfully_linked_description": "Thanks, we’ve successfully linked your GitHub account to __appName__. You can now export your __appName__ projects to GitHub, or import projects from your GitHub repositories.", "github_symlink_error": "Your GitHub repository contains symbolic link files, which are not currently supported by Overleaf. Please remove these and try again.", "github_sync_description": "With GitHub Sync you can link your __appName__ projects to GitHub repositories, create new commits from __appName__, and merge commits from GitHub.", @@ -1123,6 +1125,9 @@ "github_timeout_error": "Syncing your Overleaf project with GitHub has timed out. This may be due to the overall size of your project, or the number of files/changes to sync, being too large.", "github_too_many_files_error": "This repository cannot be imported as it exceeds the maximum number of files allowed", "github_validation_check": "Please check that the repository name is valid, and that you have permission to create the repository.", + "github_validation_check_auth": "Please check that you have permission to create the repository.", + "github_validation_name_exists": "Repository with this name already exists.", + "github_validation_name_invalid": "Repository name is invalid.", "github_workflow_authorize": "Authorize GitHub Workflow files", "github_workflow_files_delete_github_repo": "The repository has been created on GitHub but linking was unsuccessful. You will have to delete GitHub repository or choose a new name.", "github_workflow_files_error": "The __appName__ GitHub sync service couldn’t sync GitHub Workflow files (in .github/workflows/). Please authorize __appName__ to edit your GitHub workflow files and try again.", @@ -3154,7 +3159,7 @@ "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_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. (You need an active __appName__ subscription to set up a GitHub Sync).", + "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.", diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index bce29d1e56..afd34bb929 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -78,8 +78,11 @@ "apply_educational_discount_description_with_group_discount": "Получите суммарную скидку 40% для групп, использующих __appName__ в обучении", "are_you_sure_you_want_to_cancel": "Вы уверены, что хотите отменить подписку?", "are_you_sure_you_want_to_cancel_add_on": "Вы уверены, что хотите отменить расширение __addOnName__?", + "ask_proj_owner_to_add_you_as_github_collaborator": "У вас нет прав на запись в подключенный репозиторий (<0>__repoFullName__). Попросите владельца проекта (<1>__projectOwnerEmail__) добавить вас в качестве коллаборатора в этот Git-репозиторий.", + "ask_proj_owner_to_export_to_github": "Попросите владельца проекта (<0>__projectOwnerEmail__) экспортировать проект в Git-репозиторий и добавить вас в качестве коллаборатора.", "at_most_x_libraries_can_be_selected": "Можно выбрать не более __count__ библиотек", "audit_logs": "Журналы аудита", + "ask_proj_owner_to_unlink_from_current_github": "Попросите владельца проекта (<0>projectOwnerEmail) отключить проект от текущего Git-репозитория и создать подключение к другому репозиторию.", "auto_complete": "Автодополнение", "autocomplete": "Автозавершение", "autocomplete_references": "Автодополнение ссылок (внутри блока \\cite{})", @@ -108,6 +111,7 @@ "cancelling_will_end_your_subscription_on": "После отмены Ваша подписка завершится __terminationDate__.", "cant_find_email": "Извините, данный адрес не зарегистрирован.", "cant_find_page": "К сожалению, страница не найдена", + "cant_see_what_youre_looking_for_question": "Не можете найти то, что ищете?", "category": "Категория", "change": "Изменить", "change_password": "Изменение пароля", @@ -257,7 +261,10 @@ "github_commit_message_placeholder": "Сообщение о фиксации изменений в __appName__...", "github_integration_lowercase": "Интеграция с Git и GitHub", "github_is_premium": "Синхронизация с GitHub доступна только в премиум аккаунте", + "github_manual_merge_user_prompt": "Пожалуйста, вручную объедините ветку <0>__sharelatex_branch__ с веткой по умолчанию в git. Нажмите ниже, чтобы продолжить после ручного объединения.", + "github_merge_failed_error": "Ваши изменения в __appName__ и GitHub не были автоматически объединены.", "github_public_description": "Этот репозиторий может просмотреть каждый. Вы выбираете, кто может править.", + "github_repository_diverged": "Ветка по умолчанию подключённого репозитория была принудительно обновлена. Список последних коммитов ниже может быть неполным из-за переписанной истории с момента последней синхронизации.", "github_successfully_linked_description": "Спасибо, мы успешно связали ваш аккаунт на GitHub с __appName__. Теперь вы можете экспортировать проекты с __appName__ в GitHub или импортировать в обратном направлении.", "github_sync_description": "Вы можете связать ваши проекты __appName__ с репозиториями на GitHub. Создавайте коммиты в __appName__ и объединяйте их с коммитами, сделанными оффлайн или на GitHub.", "github_sync_error": "Извините, произошла ошибка в общении с сервисом GitHub. Пожалуйста, попробуйте ещё раз позднее.", @@ -485,6 +492,7 @@ "open_project": "Открыть проект", "optional": "Необязательный", "or": "или", + "organization": "Организация", "other_logs_and_files": "Другие логи и файлы", "over": "свыше", "over_n_users_at_research_institutions_and_business": "Свыше __count__ млн пользователей в исследовательских институтах и компаниях по всему миру любят __appName__", @@ -547,6 +555,7 @@ "project_categories_tags": "Категории и теги проектов", "project_history": "История проекта", "project_last_published_at": "В последний раз проект был опубликован", + "project_linked_to": "Этот проект связан с репозиторием", "project_name": "Название проекта", "project_not_linked_to_github": "Этот проект не связан ни с одним проектом на GitHub. Вы можете создать для него проект на GitHub:", "project_too_large": "Проект слишком большой", @@ -686,7 +695,7 @@ "sync_dropbox_github": "Синхронизация с Dropbox и GitHub", "sync_project_to_github_explanation": "Все изменения, сделанные Вами в __appName__ будут интегрированы (commit и merge) со всеми обновлениями на GitHub.", "sync_to_dropbox": "Синхронизация с Dropbox", - "sync_to_github": "Синхронизация с GitHub", + "sync_with_github": "Синхронизация с GitHub", "syntax_validation": "Проверка кода", "take_me_home": "Вернуться в начало", "talk_to_sales": "Связаться с отделом продаж", @@ -738,6 +747,8 @@ "unlimited_collabs": "Неограниченное число соавторов", "unlimited_projects": "Неограниченное число проектов", "unlink": "Отсоединить", + "unlink": "Отсоединить", + "unlink_github_repository": "Отсоединить Git-репозиторий", "unlink_github_warning": "Все проекты, которые Вы синхронизировали с GitHub, будут отсоединены и больше не будут синхронизироваться с GitHub. Вы уверены, что хотите отсоединить Ваш GitHub аккаунт?", "unpause_subscription": "Возобновить подписку", "unpausing": "Возобновление", @@ -818,6 +829,7 @@ "you_are_using_our_free_plan": "Вы пользуетесь бесплатным тарифом. Смените тариф, чтобы получить максимум от Overleaf", "you_can_still_use_your_premium_features": "Вы сможете пользоваться премиум-функциями до того, как приостановка вступит в силу.", "you_cant_overwrite_it": "Перезаписать нельзя.", + "you_dont_have_any_repositories": "У вас нет ни одного репозитория", "you_have_added_x_of_group_size_y": "Вы добавили <0>__addedUsersSize__ из <1>__groupSize__ доступных участников", "you_unpaused_your_subscription": "Вы возобновили подписку.", "youll_be_moved_to_the_free_plan": "Вы будете переведены на бесплатный тариф", diff --git a/services/web/modules/github-sync/app/models/githubSyncProjectStates.mjs b/services/web/modules/github-sync/app/models/githubSyncProjectStates.mjs new file mode 100644 index 0000000000..ecddcbc8dd --- /dev/null +++ b/services/web/modules/github-sync/app/models/githubSyncProjectStates.mjs @@ -0,0 +1,24 @@ +import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs" + +const { Schema } = mongoose +const { ObjectId } = Schema + +export const GitHubSyncProjectStatesSchema = new Schema( + { + projectId: { type: ObjectId, ref: 'Project', required: true, unique: true }, + repoFullName: { type: String, required: true }, + defaultBranchName: { type: String, default: null }, + mergeStatus: { type: String, enum: ['clean', 'conflict', 'diverged'], default: 'clean' }, + lastSyncCommit: { type: String, default: null }, + lastSyncVersion: { type: Number, default: null }, + unmergedBranchName: { type: String, default: null }, + unmergedBranchHead: { type: String, default: null }, + conflictVersion: { type: Number, default: null }, + }, + { collection: 'githubSyncProjectStates', minimize: false } +) + +export const GitHubSyncProjectStates = mongoose.model( + 'GitHubSyncProjectStates', + GitHubSyncProjectStatesSchema, +) diff --git a/services/web/modules/github-sync/app/models/githubSyncUserCredentials.mjs b/services/web/modules/github-sync/app/models/githubSyncUserCredentials.mjs new file mode 100644 index 0000000000..d1860334a6 --- /dev/null +++ b/services/web/modules/github-sync/app/models/githubSyncUserCredentials.mjs @@ -0,0 +1,17 @@ +import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs" + +const { Schema } = mongoose +const { ObjectId } = Schema + +export const GitHubSyncUserCredentialsSchema = new Schema( + { + userId: { type: ObjectId, ref: 'User', required: true, unique: true }, + github: { type: String, required: true }, + }, + { collection: 'githubSyncUserCredentials', minimize: false } +) + +export const GitHubSyncUserCredentials = mongoose.model( + 'GitHubSyncUserCredentials', + GitHubSyncUserCredentialsSchema, +) diff --git a/services/web/modules/github-sync/app/src/GitHubApiClient.mjs b/services/web/modules/github-sync/app/src/GitHubApiClient.mjs new file mode 100644 index 0000000000..a93e1b720f --- /dev/null +++ b/services/web/modules/github-sync/app/src/GitHubApiClient.mjs @@ -0,0 +1,495 @@ +import AbortError from 'node-fetch' +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' + +import { + fetchJson, + fetchJsonWithResponse, + fetchStreamWithResponse, + fetchNothing, + fetchStream, + RequestFailedError, +} from '@overleaf/fetch-utils' + +import { + InvalidTokenError, + NotFoundError, + GitConflictError, + RateLimitError, + ProviderRequestError, + PermissionDeniedError, + AlreadyExistsError, +} from './GitSyncErrors.mjs' + +const GITHUB_URL = 'https://github.com' +const GITHUB_API_BASE = 'https://api.github.com' +const GITHUB_GRAPHQL = 'https://api.github.com/graphql' +const MAX_PER_PAGE = 100 // GitHub REST API limit + +const REQUEST_TIMEOUT_MS = 60 * 1000 +const REQUEST_LONG_TIMEOUT_MS = 600 * 1000 + +const maxConcurrency = process.env.GITHUB_API_MAX_CONCURRENCY || 5 + +function buildHeaders(token) { + return { + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'Overleaf-CEP-GitHub-Sync', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + } +} + +// Error handling +function isRateLimitError(response) { + const remaining = response.headers.get('x-ratelimit-remaining') + const retryAfter = response.headers.get('retry-after') + + return remaining === '0' || retryAfter != null +} + +function isRepositoryAlreadyExistsError(err) { + if (err.response?.status !== 422) { + return false + } + + let body = err.body + + if (typeof body === 'string') { + try { + body = JSON.parse(body) + } catch { + return false + } + } + + const errors = body?.errors + + if (!Array.isArray(errors)) { + return false + } + + return errors.some(error => + error?.resource === 'Repository' && + error?.field === 'name' && + typeof error?.message === 'string' && + error.message.toLowerCase().includes('already exists') + ) +} + +function normalizeGitHubError(err, operation) { + logger.error({ operation }, 'GitHub API request failed') + + if (err.name === 'AbortError') { + throw new ProviderRequestError('GitHub request timed out', { status: 504 }, err) + } + + if (!(err instanceof RequestFailedError)) { + throw new ProviderRequestError('Something wrong with GitHub request', { status: 500 }, err) + } + + const status = err.response?.status || 500 + + if (status === 401) { + throw new InvalidTokenError('Invalid token', { status }, err) + } + + if (status === 404) { + throw new NotFoundError('Not found', { status }, err) + } + + if (status === 409) { + throw new GitConflictError() + } + + if (status === 403 || status === 429) { + if (isRateLimitError(err.response)) throw new RateLimitError('Rate limit exeeded', { status: 429 }, err) + throw new PermissionDeniedError('Permission denied', { status: 403 }, err) + } + + if (status === 422) { + if (isRepositoryAlreadyExistsError(err)) { + throw new AlreadyExistsError('Repository already exists', { status }, err) + } + } + + throw new ProviderRequestError('GitHub request failed', { status }, err) +} + +// wrappers +function fetchGitHubJson(url, options, operation) { + return fetchJson(url, options).catch(err => { + normalizeGitHubError(err, operation) + }) +} + +function fetchGitHubJsonWithResponse(url, options, operation) { + return fetchJsonWithResponse(url, options).catch(err => { + normalizeGitHubError(err, operation) + }) +} + +function fetchGitHubStreamWithResponse(url, options, operation) { + return fetchStreamWithResponse(url, options).catch(err => { + normalizeGitHubError(err, operation) + }) +} + +function fetchGitHubNothing(url, options, operation) { + return fetchNothing(url, options).catch(err => { + normalizeGitHubError(err, operation) + }) +} + +function fetchGitHubStream(url, options, operation) { + return fetchStream(url, options).catch(err => { + normalizeGitHubError(err, operation) + }) +} + +// ---------------------- exports ------------------------------- // + +// OAuth +function getOAuth2Url() { + const oAuthUrl = new URL(`${GITHUB_URL}/login/oauth/authorize`) + oAuthUrl.searchParams.append('client_id', Settings.githubSync.clientID) + oAuthUrl.searchParams.append('redirect_uri', Settings.githubSync.callbackURL) + oAuthUrl.searchParams.append('scope', 'read:org,repo,workflow') + return oAuthUrl +} + +function exchangeCodeForToken(code) { + return fetchGitHubJson(`${GITHUB_URL}/login/oauth/access_token`, { + method: 'POST', + headers: buildHeaders(), + json: { + code, + client_id: Settings.githubSync.clientID, + client_secret: Settings.githubSync.clientSecret, + redirect_uri: Settings.githubSync.callbackURL, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }, 'exchangeCodeForToken').then(r => r.access_token) +} + +function revokeToken(token) { + const { clientID, clientSecret } = Settings.githubSync + return fetchGitHubNothing(`${GITHUB_API_BASE}/applications/${clientID}/token`, { + method: 'DELETE', + headers: buildHeaders(), + basicAuth: { + user: clientID, + password: clientSecret, + }, + json: { access_token: token }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, 'revokeToken') +} + +// user, orgs, permissions +function getUser(token) { + return fetchGitHubJson(`${GITHUB_API_BASE}/user`, { + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, 'getUser').then(({ id, login, name }) => ({ id, login, name })) +} + +/* +async function getUserAndOrgsREST(token) { + const user = await fetchGitHubJson(`${GITHUB_API_BASE}/user`, { + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }, 'getUserAndOrgs:user') + + const page = 1 + const params = new URLSearchParams({ + page: page.toString(), + per_page: MAX_PER_PAGE.toString(), + }) + const orgs = await fetchGitHubJson(`${GITHUB_API_BASE}/user/orgs?${params}`, { + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }, 'getUserAndOrgs:orgs') + + return { + user: user.login, + orgs: orgs.map(o => o.login) + } +} +*/ + +async function getUserAndOrgs(token) { + const query = ` + query { + viewer { + login + organizations(first: 100) { + nodes { + login + viewerCanCreateRepositories + } + } + } + } + ` + const json = await fetchGitHubJson(GITHUB_GRAPHQL, { + method: 'POST', + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + json: { query } + }, 'getUserAndOrgs') + + const viewer = json.data.viewer + + return { + user: viewer.login, + orgs: viewer.organizations.nodes.map(o => o.login) + } +} + +function getPushPermission(token, repoFullName) { + return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}`, { + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, 'getPushPrmission').then(repo => (repo.permissions?.push === true)) +} + +// repos +function createRepo(token, { name, description, isPublic, org }) { + const url = org ? `${GITHUB_API_BASE}/orgs/${org}/repos` : `${GITHUB_API_BASE}/user/repos` + return fetchGitHubJson(url, { + method: 'POST', + headers: buildHeaders(token), + json: { + name, + description, + private: !isPublic, + auto_init: true, + }, + signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS) + }, 'createRepo') +} + +async function _listUserReposPage(token, page) { + const params = new URLSearchParams({ + page: page.toString(), + per_page: MAX_PER_PAGE.toString(), + }) + + const { json, response } = await fetchGitHubJsonWithResponse(`${GITHUB_API_BASE}/user/repos?${params}`, { + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, '_listUserReposPage') + + const link = response.headers.get('link') || '' + const hasNext = link.includes('rel="next"') + + return { + repos: json.map(r => ({ + name: r.name, + fullName: r.full_name, + defaultBranchName: r.default_branch, + })), + hasNext, + } +} + +async function listUserRepos(token) { + let all = [] + let page = 1 + + while (true) { + const { repos, hasNext } = await _listUserReposPage(token, page) + all = all.concat(repos) + if (!hasNext) break + page++ + } + + return all +} + +// Git (blobs / trees / commits) +function uploadBlob(token, repoFullName, buffer) { + return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/blobs`, { + method: 'POST', + headers: buildHeaders(token), + json: { + content: buffer, + encoding: 'base64', + }, + signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS) + }, 'uploadBlob').then(r => r.sha) +} + +function getBlobStream(token, repoFullName, ref, path) { + const encodedPath = encodeURIComponent(path).replace(/%2F/g, '/') + + return fetchGitHubStream(`${GITHUB_API_BASE}/repos/${repoFullName}/contents/${encodedPath}?ref=${ref}`, { + headers: { + ...buildHeaders(token), + Accept: 'application/vnd.github.raw', + }, + signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS), + }, 'getBlobStream') +} + +function createTree(token, repoFullName, entries, base_tree) { + return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/trees`, { + method: 'POST', + headers: buildHeaders(token), + json: { + tree: entries.map(e => ({ + path: e.path, + sha: e.sha, + mode: '100644', + type: 'blob', + })), + ...(base_tree && { base_tree }), + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }, 'createTree').then(r => r.sha) +} + +function createCommit(token, repoFullName, { tree, message, parents = [] }) { + return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/commits`, { + method: 'POST', + headers: buildHeaders(token), + json: { + tree, + message, + parents, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }, 'createCommit').then(r => r.sha) +} + +function getCommitTree(token, repoFullName, commit) { + return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/commits/${commit}`, { + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }, 'getCommitTree').then(r => r.tree.sha) +} + +function listBlobsAtCommit(token, repoFullName, commit) { +// can use commit sha here instead of tree sha + return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/trees/${commit}?recursive=1`, { + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS) + }, 'listBlobsAtCommit').then(r => + (r.tree || []) + .filter(entry => entry.type === 'blob') + .map(entry => ({ + sha: entry.sha, + path: entry.path + })) + ) +} + +async function listNewCommitsWithStatus(token, fullName, branchName, fromCommit) { + const url = `${GITHUB_API_BASE}/repos/${fullName}/compare/${fromCommit}...${encodeURIComponent(branchName)}` + const data = await fetchGitHubJson(url, { + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS) + }, 'listNewCommitsWithStatus') + + const commits = (data.commits || []).map(c => ({ + message: c.commit?.message || '', + author: { + name: c.commit?.author?.name || '', + email: c.commit?.author?.email || '', + date: c.commit?.author?.date || '', + }, + sha: c.sha, + })) + const diverged = (data.status === 'diverged' || data.status === 'behind') + return { commits, diverged } +} + +// branches +function getBranchHead(token, repoFullName, branchName) { + return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/ref/heads/${encodeURIComponent(branchName)}`, { + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }, 'getBranchHead').then(r => r.object.sha) +} + +function createBranch(token, repoFullName, branchName, sha) { + return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/refs`, { + method: 'POST', + headers: buildHeaders(token), + json: { + ref: `refs/heads/${branchName}`, + sha, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }, 'createBranch') +} + +function updateBranch(token, repoFullName, branchName, sha, force = false) { + return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/refs/heads/${encodeURIComponent(branchName)}`, { + method: 'PATCH', + headers: buildHeaders(token), + json: { sha, force }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }, 'updateBranch') +} + +function deleteBranch(token, repoFullName, branchName) { + return fetchGitHubNothing(`${GITHUB_API_BASE}/repos/${repoFullName}/git/refs/heads/${encodeURIComponent(branchName)}`, { + method: 'DELETE', + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }, 'deleteBranch') +} + +// merge / compare +function mergeBranch(token, repoFullName, base, head) { + return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/merges`, { + method: 'POST', + headers: buildHeaders(token), + json: { base, head }, + signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS) + }, 'mergeBranch').then(r => r.sha) +} + +function compareCommits(token, repoFullName, from, to) { + return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/compare/${from}...${to}`, { + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }, 'compareCommits').then(r => { r.files || [] }) +} + +// zip +function getRepoZipball(token, repoFullName, sha) { + return fetchGitHubStream(`${GITHUB_API_BASE}/repos/${repoFullName}/zipball/${sha}`, { + headers: buildHeaders(token), + signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS), + }, 'getRepoZipball') +} + +export default { + maxConcurrency, + getOAuth2Url, + exchangeCodeForToken, + revokeToken, + getUser, + getUserAndOrgs, + getPushPermission, + createRepo, + listUserRepos, + uploadBlob, + getBlobStream, + createTree, + createCommit, + getCommitTree, + listBlobsAtCommit, + listNewCommitsWithStatus, + getBranchHead, + createBranch, + updateBranch, + deleteBranch, + mergeBranch, + compareCommits, + getRepoZipball, +} diff --git a/services/web/modules/github-sync/app/src/GitHubSyncController.mjs b/services/web/modules/github-sync/app/src/GitHubSyncController.mjs new file mode 100644 index 0000000000..a821db88a7 --- /dev/null +++ b/services/web/modules/github-sync/app/src/GitHubSyncController.mjs @@ -0,0 +1,239 @@ +import { expressify } from '@overleaf/promise-utils' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' +import Csrf from '../../../../app/src/infrastructure/Csrf.mjs' +import GitHubSyncHandler from './GitHubSyncHandler.mjs' +import TokenManager from './TokenManager.mjs' +import api from './GitHubApiClient.mjs' +import { doGitMerge } from './GitMerge.mjs' +import { InvalidTokenError, GitNotLinkedError, AlreadyExistsError } from './GitSyncErrors.mjs' + +async function getConnectionStatus(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + + try { + const isConnected = await GitHubSyncHandler.getGitConnState(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 }) + } +} + +async function getProjectState(req, res) { + const { project_id: projectId } = req.params + const userId = SessionManager.getLoggedInUserId(req.session) + + try { + const pss = await GitHubSyncHandler.getProjectState(userId, projectId) + return res.json(pss) + } catch (err) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error(OError.getFullStack(err)) + logger.error({ info }, "failed get project sync state") + return res.status(errStatus).json({ message: err.message }) + } +} + +async function getUserAndOrgs(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + const userAndOrgs = await GitHubSyncHandler.getUserAndOrgs(userId) + res.json(userAndOrgs) +} +async function listUserRepos(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + + try { + const repos = await GitHubSyncHandler.listUserRepos(userId) + res.json({ repos }) + + } catch (err) { + if (err instanceof InvalidTokenError) { + return res.json(null) + } + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error(OError.getFullStack(err)) + logger.error({ userId, info }, 'Failed to get repositories list') + return res.status(errStatus).json({ message: err.message }) + } +} + +async function getMergeOverview(req, res) { + const { project_id: projectId } = req.params + const userId = SessionManager.getLoggedInUserId(req.session) + try { + const commitsAndStatus = await GitHubSyncHandler.getMergeOverview(userId, projectId) + res.json(commitsAndStatus) + + } catch (err) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error(OError.getFullStack(err)) + logger.error({ info, projectId }, 'Error listing commits since last sync') + return res.status(errStatus).json({ message: err.message }) + } +} + +// Import a GitHub repository as a new project +async function importRepo(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + const { name, fullName, defaultBranchName } = req.body + + try { + const projectId = await GitHubSyncHandler.importRepo(userId, name, fullName, defaultBranchName) + res.json({ projectId }) + } catch (error) { + logger.error({ error, userId }, 'Failed to import git repository from server') + res.status(error.status || 500).json({ message: error.message }) + } +} + +// Redirect user to Git server OAuth2 authorization URL +async function oauth2(req, res) { + const oauth2Url = api.getOAuth2Url() + oauth2Url.searchParams.append('state', req.csrfToken()) + res.redirect(oauth2Url.toString()) +} + +// callback +async function oauth2Callback(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + const { code, state } = req.query + + try { + await Csrf.promises.validateToken(state, req.session) + } catch { + HttpErrorHandler.forbidden(req, res, 'Invalid CSRF token') + return + } + + let token + try { + token = await api.exchangeCodeForToken(code) + if (!token) { + HttpErrorHandler.badRequest(req, res, 'Failed to obtain access token from Git server') + return + } + } catch (error) { + const info = OError.getFullInfo(err) + logger.error(OError.getFullStack(err)) + logger.error({ info, userId }, 'Failed to obtain access token from Git server') + HttpErrorHandler.badRequest(req, res, error.message || 'Bad request') + return + } + + try { + await TokenManager.saveUserToken(userId, token) + } catch (err) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error(OError.getFullStack(err)) + logger.error({ info, userId }, 'Error saving user token') + HttpErrorHandler.handleErrorByStatusCode(req, res, err, errStatus) + return + } + + // Save success message in session to display on redirect + req.session.projectSyncSuccessMessage = + req.i18n.translate('github_successfully_linked_description') + res.redirect('/user/settings?oauth-complete=github#project-sync') +} + +// Unlink user's Git server account +async function unlink(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + try { + await TokenManager.removeUserToken(userId) + } catch (err) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error(OError.getFullStack(err)) + logger.error({ info, projectId }, 'Error removing user credentials') + return res.status(errStatus).json({ message: err.message }) + } + res.sendStatus(200) +} + +// Export project to Git server +// Expected req.body: +// name (string): repository name +// description (string, optional): repository description +// isPublic (boolean, optional): if true, the repository is public +// org (string, optional): if provided, repository will be created under this organization + +async function exportProject(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + const { project_id: projectId } = req.params + + try { + await GitHubSyncHandler.exportProject(userId, projectId, req.body) + + } catch (err) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error(OError.getFullStack(err)) + logger.error({ info, projectId }, 'Error exporting project') + let key = 'github_validation_check' + if (err instanceof AlreadyExistsError) key = 'github_validation_name_exists' + else if (errStatus === 401 || errStatus === 403) key = 'github_validation_check_auth' + return res.status(errStatus).json({ key, message: err.message }) + } + res.sendStatus(200) +} + +async function gitMerge(req, res) { + const { project_id: projectId } = req.params + try { + const userId = SessionManager.getLoggedInUserId(req.session) + const commitMessage = req.body?.message || 'Updates from Overleaf' + const claimConflictIsResolved = req.body.claimConflictIsResolved + + const mergeResult = await doGitMerge(userId, projectId, commitMessage, claimConflictIsResolved) + + res.json(mergeResult) + } catch (err) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error(OError.getFullStack(err)) + logger.error({ info, projectId }, 'Error syncing project to GitHub') + return res.status(errStatus).json({ message: err.message }) + } +} + +// Unlink user's Git server account +async function unlinkRepo(req, res) { + const { project_id: projectId } = req.params + const userId = SessionManager.getLoggedInUserId(req.session) + try { + const ownerEmail = await GitHubSyncHandler.unlinkRepo(userId, projectId) + if (ownerEmail) return res.status(403).json({ ownerEmail }) + } catch (err) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error(OError.getFullStack(err)) + logger.error({ info, projectId }, 'Error unlinking repo from project') + return res.status(errStatus).json({ message: err.message }) + } + res.sendStatus(200) +} +export default { + getConnectionStatus: expressify(getConnectionStatus), + getProjectState: expressify(getProjectState), + oauth2: expressify(oauth2), + unlink: expressify(unlink), + getUserAndOrgs: expressify(getUserAndOrgs), + oauth2Callback: expressify(oauth2Callback), + listUserRepos: expressify(listUserRepos), + importRepo: expressify(importRepo), + exportProject: expressify(exportProject), + getMergeOverview: expressify(getMergeOverview), + gitMerge: expressify(gitMerge), + unlinkRepo: expressify(unlinkRepo), +} diff --git a/services/web/modules/github-sync/app/src/GitHubSyncHandler.mjs b/services/web/modules/github-sync/app/src/GitHubSyncHandler.mjs new file mode 100644 index 0000000000..6b6e711ae4 --- /dev/null +++ b/services/web/modules/github-sync/app/src/GitHubSyncHandler.mjs @@ -0,0 +1,241 @@ +import Path from 'path' +import fs from 'fs' +import { pipeline } from 'node:stream/promises' +import crypto from 'crypto' +import pLimit from 'p-limit' +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs' +import ProjectUploadManager from '../../../../app/src/Features/Uploads/ProjectUploadManager.mjs' +import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' +import DocumentUpdaterHandler from '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs' +import api from './GitHubApiClient.mjs' +import SyncStateManager from './SyncStateManager.mjs' +import HistoryManager from './HistoryManager.mjs' +import TokenManager from './TokenManager.mjs' +import { InvalidTokenError, NotFoundError } from './GitSyncErrors.mjs' + +async function getGitConnState(userId) { + try { + const token = await TokenManager.getUserToken(userId) + await api.getUser(token) + return true + } catch (err) { + if (err instanceof InvalidTokenError) { + logger.debug( { err, userId }, 'token invalid, treating as not connected') + return false + } + throw OError.tag(err, 'failed to validate token', { userId }) + } +} + +async function getProjectState(userId, projectId) { + let pss = null + const projection = { _id: 0, mergeStatus: 1, repoFullName: 1, unmergedBranchName: 1 } + pss = await SyncStateManager.getProjectState(projectId, projection) + if (!pss) { + pss = { mergeStatus: 'need-export' } + try { + const { owner_ref } = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1 }) + if (owner_ref.toString() !== userId) { + pss.ownerEmail = await UserGetter.promises.getUserEmail(owner_ref) + } + } catch (err) { + pss.ownerEmail = 'nobody@nowhere' + logger.error({ err }, "failed get user email") + } + return pss + } + let canPush + try { + const token = await TokenManager.getUserToken(userId) + canPush = await api.getPushPermission(token, pss.repoFullName) + } catch (err) { + if ((err instanceof NotFoundError) || + (err instanceof PermissionDeniedError) + ) canPush = false + else throw err + } + if (!canPush) { + pss.mergeStatus = 'need-permission' + // send owner's email to collaborator + // send nothing to the owner + try { + const { owner_ref } = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1 }) + if (owner_ref.toString() !== userId) { + pss.ownerEmail = await UserGetter.promises.getUserEmail(owner_ref) + } + } catch (err) { + logger.error({ err, userId }, "failed get user email") + pss.ownerEmail = 'nobody@nowhere' + } + } + return pss +} + +async function unlinkRepo(userId, projectId) { + const { owner_ref } = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1 }) + if (owner_ref.toString() !== userId) { + let ownerEmail + try { + ownerEmail = await UserGetter.promises.getUserEmail(owner_ref) + } catch (err) { + logger.error({ err, userId }, "failed get user email") + ownerEmail = 'nobody@nowhere' + } + return ownerEmail + } + await SyncStateManager.removeProjectState(projectId) + return null +} + + +async function listUserRepos(userId) { + const token = await TokenManager.getUserToken(userId) + return await api.listUserRepos(token) +} + +async function getUserAndOrgs(userId) { + const token = await TokenManager.getUserToken(userId) + return await api.getUserAndOrgs(token) +} + +async function getMergeOverview(userId, projectId) { + const projectSyncState = await SyncStateManager.getProjectState(projectId) + if (!projectSyncState) throw new GitNotLinkedError(projectId) + const { repoFullName, defaultBranchName, lastSyncCommit, lastSyncVersion, mergeStatus } = projectSyncState + + if (mergeStatus === 'conflict') return null + + const token = await TokenManager.getUserToken(userId) + const currentVersion = await HistoryManager.latestVersion(projectId.toString()) + const isProjectUpdated = currentVersion !== lastSyncVersion + try { + const commitsAndStatus = await api.listNewCommitsWithStatus(token, repoFullName, defaultBranchName, lastSyncCommit) + if(commitsAndStatus.diverged) { + await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'diverged' } ) + } + return { ...commitsAndStatus, isProjectUpdated } + } catch (err) { + if (err instanceof NotFoundError) { + // what is the reason of NotFoundError? is it a missing commit or not? + let canPush + try { + canPush = await api.getPushPermission(token, repoFullName) + } catch (e) { + logger.error({ error: e }, "failed to check push permission") + canPush = false + } + if (!canPush) throw err + await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'diverged' } ) + return { commits: [], diverged: true, isProjectUpdated } + } else { + throw err + } + } +} + +async function importRepo(userId, projectName, repoFullName, defaultBranchName) { + const token = await TokenManager.getUserToken(userId) + const defaultBranchHead = await api.getBranchHead(token, repoFullName, defaultBranchName) + const fsPath = Path.join(Settings.path.dumpFolder, `github_import_${crypto.randomUUID()}`) + + let project_id + + try { + const stream = await api.getRepoZipball(token, repoFullName, defaultBranchHead) + await pipeline(stream, fs.createWriteStream(fsPath)) + + const { project } = await ProjectUploadManager.promises.createProjectFromZipArchiveWithName( + userId, + projectName, + fsPath + ) + + project_id = project?._id + } catch (err) { + throw OError.tag( + err, + 'failed importing git repo', + { userId, projectName, repoFullName, defaultBranchName, fsPath } + ) + } finally { + fs.promises.rm(fsPath, { force: true }).catch(() => {}) + } + + try { + const projectVersion = await HistoryManager.latestVersion(project_id.toString()) + await SyncStateManager.createProjectState(project_id, { + repoFullName, + mergeStatus: 'clean', + lastSyncCommit: defaultBranchHead, + defaultBranchName, + lastSyncVersion: projectVersion + }) + } catch (err) { +// don't throw up + logger.error( + { err, userId, projectId: project_id.toString(), repoFullName }, + 'Failed to create state of imported repo' + ) + } + + return project_id +} + +async function exportProject(userId, projectId, repoOptions) { + const isLinked = await SyncStateManager.getProjectState(projectId) + if (isLinked) { + throw new OError('Project is already linked to Git server', { projectId }) + } + + const token = await TokenManager.getUserToken(userId) + + const { full_name: repoFullName, default_branch: defaultBranchName } = + await api.createRepo(token, repoOptions) + + await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId) + + const currentVersion = await HistoryManager.latestVersion(projectId) + const currentPaths = await HistoryManager.getPathsAtVersion(projectId, currentVersion) + + const limit = pLimit(api.maxConcurrency) + const blobData = await Promise.all( + currentPaths.paths.map((path) => + limit(async () => { + const buffer = await HistoryManager.getProjectFileBuffer(projectId, currentVersion, path) + const sha = await api.uploadBlob(token, repoFullName, buffer) + return { path, sha } + }) + ) + ) + + const tree = await api.createTree(token, repoFullName, blobData) + const initialCommit = await api.createCommit(token, repoFullName, { + tree, + message: 'Initial Overleaf import', + }) + + const force = true + await api.updateBranch(token, repoFullName, defaultBranchName, initialCommit, force) + + return SyncStateManager.createProjectState(projectId, { + mergeStatus: 'clean', + defaultBranchName, + lastSyncCommit: initialCommit, + lastSyncVersion: currentVersion, + repoFullName, + }) +} + +export default { + exportProject, + getProjectState, + unlinkRepo, + importRepo, + getGitConnState, + listUserRepos, + getUserAndOrgs, + getMergeOverview, +} diff --git a/services/web/modules/github-sync/app/src/GitHubSyncRouter.mjs b/services/web/modules/github-sync/app/src/GitHubSyncRouter.mjs new file mode 100644 index 0000000000..a84ed987d6 --- /dev/null +++ b/services/web/modules/github-sync/app/src/GitHubSyncRouter.mjs @@ -0,0 +1,100 @@ +import logger from '@overleaf/logger' + +import GitHubSyncController from './GitHubSyncController.mjs' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init github-sync router') + // start the GitHub OAuth flow by redirecting to GitHub authorization page + webRouter.get( + '/user/github-sync/oauth2', + AuthenticationController.requireLogin(), + GitHubSyncController.oauth2 + ) + + // callback for GitHub OAuth flow + webRouter.get( + '/user/github-sync/oauth2/callback', + AuthenticationController.requireLogin(), + GitHubSyncController.oauth2Callback + ) + + // unlink GitHub account + webRouter.post( + '/user/github-sync/unlink', + AuthenticationController.requireLogin(), + GitHubSyncController.unlink + ) + + // get user git connection status + webRouter.get( + '/user/github-sync/status', + AuthenticationController.requireLogin(), + GitHubSyncController.getConnectionStatus + ) + + // get git user name and user's organizations + webRouter.get( + '/user/github-sync/orgs', + AuthenticationController.requireLogin(), + GitHubSyncController.getUserAndOrgs + ) + + // list user's repos + webRouter.get( + '/user/github-sync/repos', + AuthenticationController.requireLogin(), + GitHubSyncController.listUserRepos + ) + + // create a new project from git server repo + webRouter.post( + '/project/new/github-sync', + AuthenticationController.requireLogin(), + GitHubSyncController.importRepo + ) + + // export project to git server + webRouter.post( + '/project/:project_id/github-sync/export', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + GitHubSyncController.exportProject + ) + + // get project sync state + webRouter.get( + '/project/:project_id/github-sync/state', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + GitHubSyncController.getProjectState + ) + + // get overview of the coming merge: + // merge state (clean or diverged), unmerged commits, is OL version changed? + webRouter.get( + '/project/:project_id/github-sync/merge/overview', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + GitHubSyncController.getMergeOverview + ) + + // merge OL project with Git server repo + webRouter.post( + '/project/:project_id/github-sync/merge', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + GitHubSyncController.gitMerge + ) + + // unlink Git repo from Git server + webRouter.delete( + '/project/:project_id/github-sync', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + GitHubSyncController.unlinkRepo + ) + }, +} diff --git a/services/web/modules/github-sync/app/src/GitMerge.mjs b/services/web/modules/github-sync/app/src/GitMerge.mjs new file mode 100644 index 0000000000..2d60cfcb28 --- /dev/null +++ b/services/web/modules/github-sync/app/src/GitMerge.mjs @@ -0,0 +1,783 @@ +import { normalize } from 'path/posix' +import pLimit from 'p-limit' +import { createHash } from 'crypto' +import logger from '@overleaf/logger' +import SyncStateManager from './SyncStateManager.mjs' +import api from './GitHubApiClient.mjs' +import HistoryManager from './HistoryManager.mjs' +import { GitConflictError } from './GitSyncErrors.mjs' +import TokenManager from './TokenManager.mjs' +import { ObjectId } from '../../../../app/src/infrastructure/mongodb.mjs' +import LockManager from '../../../../app/src/infrastructure/LockManager.mjs' +import ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs' +import UpdateMerger from '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs' +import DocumentUpdaterHandler from '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs' + +const SyncLockManager = LockManager.withTimeout(300) // 5 min + +export async function doGitMerge(userId, projectId, message, claimConflictIsResolved) { + return await SyncLockManager.promises.runWithLock( + 'gitSync', + projectId, + () => doGitMergeWithoutLock(userId, projectId, message, claimConflictIsResolved) + ) +} + +async function doGitMergeWithoutLock(userId, projectId, message, claimConflictIsResolved) { + const projectSyncState = await SyncStateManager.getProjectState(projectId) + if (!projectSyncState) throw new GitNotLinkedError(projectId) + const { + repoFullName, + defaultBranchName, + mergeStatus, + unmergedBranchName + } = projectSyncState + + // do nothing if conflict is not yet resolved and 'Sync' button is pushed + // do nothing if conflict is already resolved and 'I have manually merged' button is pushed + if ( + !claimConflictIsResolved && mergeStatus === 'conflict' || + claimConflictIsResolved && mergeStatus !== 'conflict' + ) return { mergeStatus, repoFullName, unmergedBranchName } + + const token = await TokenManager.getUserToken(userId) + + await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId) + + const currentVersion = await HistoryManager.latestVersion(projectId) + const defaultBranchHead = await api.getBranchHead(token, repoFullName, defaultBranchName) + + let newUnmergedBranchName + + switch (mergeStatus) { + + case 'clean': + newUnmergedBranchName = await resolveCleanSyncState( + token, + userId, + projectSyncState, + currentVersion, + defaultBranchHead, + message, + ) + break + + case 'conflict': + newUnmergedBranchName = await resolveConflictSyncState( + token, + userId, + projectSyncState, + currentVersion, + defaultBranchHead + ) + break + + case 'diverged': + newUnmergedBranchName = await resolveDetachedSyncState( + token, + userId, + projectSyncState, + currentVersion, + defaultBranchHead, + message, + ) + break + + default: + throw new Error('Bad mergeStatus') + } + + const newMergeStatus = newUnmergedBranchName ? 'conflict' : 'clean' + return { mergeStatus: newMergeStatus, repoFullName, unmergedBranchName: newUnmergedBranchName } +} + +// ----------- clean flow +async function resolveCleanSyncState( + token, + userId, + projectSyncState, + currentVersion, + defaultBranchHead, + message, +) { + const { projectId, repoFullName, defaultBranchName } = projectSyncState + + // No changes in OL + if (currentVersion === projectSyncState.lastSyncVersion) { + // No changes in GH too: everything is up to date + if (projectSyncState.lastSyncCommit === defaultBranchHead) return null + + // Need to apply only GH changes + const lastSyncCommit = defaultBranchHead + const lastSyncVersion = await applyGitSnapshotToProject({ + token, + userId, + projectId, + repoFullName, + lastSyncCommit, + }) + + await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit }) + return null + } + + // Possible changes in OL + const olBranchHead = + await exportChangesToGit({ + token, + projectId, + repoFullName, + lastSyncVersion: projectSyncState.lastSyncVersion, + currentVersion, + message, + baseCommit: projectSyncState.lastSyncCommit, + }) + + if (!olBranchHead) { + if ( defaultBranchHead === projectSyncState.lastSyncCommit) { + await SyncStateManager.updateProjectState(projectId, { lastSyncVersion: currentVersion }) + return null + } + + const lastSyncCommit = defaultBranchHead + const lastSyncVersion = await applyGitSnapshotToProject({ + token, + userId, + projectId, + repoFullName, + lastSyncCommit, + }) + await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit }) + return null + } + + if (defaultBranchHead === projectSyncState.lastSyncCommit) { + // Trying to FF + try { + await api.updateBranch(token, repoFullName, defaultBranchName, olBranchHead) + + const lastSyncCommit = olBranchHead + const lastSyncVersion = await applyGitSnapshotToProject({ + token, + userId, + projectId, + repoFullName, + lastSyncCommit, + }) + await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit }) + return null + + } catch (err) { + if (!(err instanceof GitConflictError)) { throw err } + } + } + + // FF failed, fallback to merge + const { mergeCommit, tempBranchName, conflict } = await mergeWithTempBranch( + token, + repoFullName, + defaultBranchName, + olBranchHead + ) + + if (conflict) { + await SyncStateManager.updateProjectState(projectId, { + mergeStatus: 'conflict', + lastSyncCommit: defaultBranchHead, + unmergedBranchName: tempBranchName, + unmergedBranchHead: olBranchHead, + conflictVersion: currentVersion, + }) + return tempBranchName + } else { + const lastSyncCommit = mergeCommit + const lastSyncVersion = await applyGitSnapshotToProject({ + token, + userId, + projectId, + repoFullName, + lastSyncCommit, + }) + await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit }) + return null + } +} + +// ----------- conflict flow +async function resolveConflictSyncState( + token, + userId, + projectSyncState, + currentVersion, + defaultBranchHead +) { + const { projectId, repoFullName, defaultBranchName, conflictVersion } = projectSyncState + + // No OL changes after manual conflict resolving: pull GH changes only + if (currentVersion === conflictVersion) { + const lastSyncCommit = defaultBranchHead + const lastSyncVersion = await applyGitSnapshotToProject({ + token, + userId, + projectId, + repoFullName, + lastSyncCommit, + }) + + await SyncStateManager.updateProjectState(projectId, { + mergeStatus: 'clean', + lastSyncVersion, + lastSyncCommit, + conflictVersion: null, + unmergedBranchName: null, + unmergedBranchHead: null + }) + return null + } + + // OL was changed after manual conflict resolution: need to merge + const prevOlBranchHead = projectSyncState.unmergedBranchHead + + const newOlBranchHead = await exportChangesToGit({ + token, + projectId, + repoFullName, + lastSyncVersion: conflictVersion, + currentVersion, + message: '[Updates in Overleaf during conflict resolution]', + baseCommit: prevOlBranchHead, + }) + + // Are there really were changes? (e.g.: file created, then removedi: no changes) + if (!newOlBranchHead) { + // nothing to push to GH + const lastSyncCommit = defaultBranchHead + const lastSyncVersion = await applyGitSnapshotToProject({ + token, + userId, + projectId, + repoFullName, + lastSyncCommit, + }) + + await SyncStateManager.updateProjectState(projectId, { + mergeStatus: 'clean', + lastSyncVersion, + lastSyncCommit, + conflictVersion: null, + unmergedBranchName: null, + unmergedBranchHead: null + }) + return null + } + + // merge + const { mergeCommit, tempBranchName, conflict } = await mergeWithTempBranch( + token, + repoFullName, + defaultBranchName, + newOlBranchHead, + ) + + if (conflict) { + // conflict must be resolved manually + await SyncStateManager.updateProjectState(projectId, { + mergeStatus: 'conflict', + lastSyncCommit: defaultBranchHead, + unmergedBranchName: tempBranchName, + unmergedBranchHead: newOlBranchHead, + conflictVersion: currentVersion, + }) + return tempBranchName + } else { + // merge succeeded + const lastSyncCommit = mergeCommit + const lastSyncVersion = await applyGitSnapshotToProject({ + token, + userId, + projectId, + repoFullName, + lastSyncCommit, + }) + await SyncStateManager.updateProjectState(projectId, { + mergeStatus: 'clean', + lastSyncVersion, + lastSyncCommit, + conflictVersion: null, + unmergedBranchName: null, + unmergedBranchHead: null + }) + return null + } +} + +// ----------- detached flow +// GH repo was force pushed, lastSybc commit possibly anavailable +async function resolveDetachedSyncState( + token, + userId, + projectSyncState, + currentVersion, + defaultBranchHead, + message, +) { + const { projectId, repoFullName, defaultBranchName } = projectSyncState + + // No changes in OL: re-anchor + if (currentVersion === projectSyncState.lastSyncVersion) { + const lastSyncCommit = defaultBranchHead + const lastSyncVersion = await applyGitSnapshotToProject({ + token, + userId, + projectId, + repoFullName, + lastSyncCommit, + }) + await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'clean', lastSyncVersion, lastSyncCommit }) + return null + } + + // Need to compare lastSync OL version (base), current OL version (local) and GH version (remote) + const baseSnapshot = await HistoryManager.getProjectSnapshot(projectId, projectSyncState.lastSyncVersion) + const localSnapshot = await HistoryManager.getProjectSnapshot(projectId, currentVersion) + const remoteBlobMap = await getGitBlobMap(token, repoFullName, defaultBranchHead) + + const { + cleanLocalEntries, + conflictRemoteEntries, + conflictLocalEntries, + conflictBaseEntries, + hasConflicts, + hasChanges + } = await buildDetachedSyncPlan({ + token, + repoFullName, + projectId, + currentVersion, + baseSnapshot, + localSnapshot, + remoteBlobMap, + }) + + // were any changes introduced in GH? + if (!hasChanges) { + await SyncStateManager.updateProjectState(projectId, { + mergeStatus: 'clean', + lastSyncVersion: currentVersion, + lastSyncCommit: defaultBranchHead + }) + return null + } + + if (!hasConflicts) { + let lastSyncCommit = defaultBranchHead + if (cleanLocalEntries.length !== 0) { + lastSyncCommit = await createCommitFromEntries({ + token, + repoFullName, + parentCommit: defaultBranchHead, + entries: cleanLocalEntries, + message, + }) + await api.updateBranch(token, repoFullName, defaultBranchName, lastSyncCommit) + } + const lastSyncVersion = await applyGitSnapshotToProject({ + token, + userId, + projectId, + repoFullName, + lastSyncCommit, + }) + await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'clean', lastSyncVersion, lastSyncCommit }) + return null + } + + // Resolve potential conflict. + // Build synthetic merge topology: + // + // GH + // / + // H --- B --- + // \ + // OL + // + // merge(GH <- OL) + // + // B: conflicted files are restored to lastSyncVersion (merge base) + // GH: in conflicted files remote changes after lastSyncVersion are replayed + // OL: all changes done in Overleaf after lastSyncVersion are applied + + // Blobs referenced in conflictBaseEntries in theory could be lost, but for GitHub, + // GitLab and Gitea, blobs from historical commits are normally retained for a + // very long time so the practical probability of failure is likely close to zero. + // TODO: anyway, if error is thrown by api.createTree, upload all blobs + // referenced in conflictBaseEntries and try again + + // H --- B + const baseCommit = await createCommitFromEntries({ + token, + repoFullName, + parentCommit: defaultBranchHead, + entries: conflictBaseEntries, + message: '[Overleaf GitSync conflict resolution: restore last sync]', + }) + + // H --- B --- GH + const remoteChangesCommit = await createCommitFromEntries({ + token, + repoFullName, + parentCommit: baseCommit, + entries: conflictRemoteEntries, + message: '[Overleaf GitSync conflict resolution: replay repo changes]', + }) + + // H --- B --- OL + const olChangesCommit = await createCommitFromEntries({ + token, + repoFullName, + parentCommit: baseCommit, + entries: [...cleanLocalEntries, ...conflictLocalEntries], + message, + }) + + // default branch: --- H --- B --- GH + await api.updateBranch( + token, + repoFullName, + defaultBranchName, + remoteChangesCommit + ) + + // temp branch: --- H --- B --- OL + // merge: default <- temp (GH <- OL) + const { mergeCommit, tempBranchName, conflict } = await mergeWithTempBranch( + token, + repoFullName, + defaultBranchName, + olChangesCommit + ) + + // auto-merge succeeded, conflicts in editable files were resolved? + if (!conflict) { + const lastSyncCommit = mergeCommit + const lastSyncVersion = await applyGitSnapshotToProject({ + token, + userId, + projectId, + repoFullName, + lastSyncCommit, + }) + await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'clean', lastSyncVersion, lastSyncCommit }) + return null + } + + // merge failed: must manually resolve conflicts + await SyncStateManager.updateProjectState(projectId, { + mergeStatus: 'conflict', + lastSyncCommit: remoteChangesCommit, + unmergedBranchName: tempBranchName, + unmergedBranchHead: olChangesCommit, + conflictVersion: currentVersion, + }) + return tempBranchName +} + +// ----------- Helpers +async function applyGitSnapshotToProject({ token, userId, projectId, repoFullName, lastSyncCommit }) { + + const invalidFiles = [] + const updates = [] + const deletions = [] + + const currentVersion = await HistoryManager.latestVersion(projectId) + const historySnapshot = await HistoryManager.getProjectSnapshot(projectId, currentVersion) + const blobMap = await getGitBlobMap(token, repoFullName, lastSyncCommit) + const blobPaths = new Set() + + for (const [path, sha] of Object.entries(blobMap)) { + const invalid = validateFileName(path) + + if (invalid) { + invalidFiles.push(invalid.name) + continue + } + + blobPaths.add(path) + + const hstSha = historySnapshot[path]?.data?.hash + // File at path is not changed, do not touch it + if (hstSha === sha) continue + + updates.push(path) + } + + for (const path of Object.keys(historySnapshot)) { + if (blobPaths.has(path)) continue + deletions.push(path) + } + + // TODO: do not silently ignore invalidFiles, inform user about them + if (invalidFiles.length > 0) { + logger.warn({ projectId, invalidFiles }, 'Invalid file paths detected at Git server') + } + const limit = pLimit(api.maxConcurrency) + await Promise.all( + updates.map(path => limit(async () => { + const stream = await api.getBlobStream(token, repoFullName, lastSyncCommit, path) + return UpdateMerger.promises.mergeUpdate(userId, projectId, '/' + path, stream, 'github') + })) + ) + const localLimit = pLimit(5) + await Promise.all( + deletions.map(path => localLimit(() => { + return UpdateMerger.promises.deleteUpdate(userId, projectId, '/' + path, 'github') + })) + ) + + return await HistoryManager.latestVersion(projectId) +} + +async function buildDetachedSyncPlan({ + token, + repoFullName, + projectId, + currentVersion, + baseSnapshot, + localSnapshot, + remoteBlobMap +}) { + const cleanLocalEntries = [] + const conflictRemoteEntries = [] + const conflictLocalEntries = [] + const conflictBaseEntries = [] + + let hasConflicts = false + let hasChanges = false + + const allPaths = new Set([ + ...Object.keys(baseSnapshot || {}), + ...Object.keys(localSnapshot || {}), + ...Object.keys(remoteBlobMap || {}), + ]) + + for (const path of allPaths) { + const baseHash = getEntryHash(baseSnapshot[path]) + const localHash = getEntryHash(localSnapshot[path]) + const remoteHash = remoteBlobMap[path] || null + + // currentVesion of the file is not known to git server, uploading blob + if (localHash && localHash !== baseHash && localHash !== remoteHash) { + let buffer + if (localSnapshot[path].data?.content) { + buffer = Buffer.from(localSnapshot[path].data.content, 'utf8').toString('base64') + } else { + buffer = await HistoryManager.getProjectFileBuffer(projectId, currentVersion, path) + } + const sha = await api.uploadBlob(token, repoFullName, buffer) + // assert sha !== localHash + } + + // OL == GH, do nothing + if (localHash === remoteHash) continue + + if (baseHash === remoteHash) { + // changed in OL + hasChanges = true + cleanLocalEntries.push({ path, sha: localHash }) + continue + } + + if (baseHash === localHash) { + // changed in GH + hasChanges = true + continue + } + // conflict: changed both in GH and OL + hasChanges = true + hasConflicts = true + + conflictRemoteEntries.push({ path, sha: remoteHash }) + conflictLocalEntries.push({ path, sha: localHash}) + conflictBaseEntries.push({ path, sha: baseHash }) + } + return { + cleanLocalEntries, + conflictRemoteEntries, + conflictLocalEntries, + conflictBaseEntries, + hasConflicts, + hasChanges, + } +} + +async function createCommitFromEntries({ + token, + repoFullName, + parentCommit, + entries, + message, +}) { + const baseTree = await api.getCommitTree(token, repoFullName, parentCommit) + const newTree = await api.createTree(token, repoFullName, entries, baseTree) + return api.createCommit(token, repoFullName, { + tree: newTree, + parents: [parentCommit], + message, + }) +} + +async function mergeWithTempBranch( + token, + repoFullName, + branchName, + updateCommit +) { + const tempBranchName = generateBranchName() + + await api.createBranch( + token, + repoFullName, + tempBranchName, + updateCommit + ) + + try { + const mergeCommit = + await api.mergeBranch(token, repoFullName, branchName, tempBranchName) + + try { + await api.deleteBranch(token, repoFullName, tempBranchName) + } catch (err) { + logger.warn({ err, repoFullName, tempBranchName }, 'Delete temp branch failed') + } + + return { conflict: false, mergeCommit } + + } catch (err) { + if (err instanceof GitConflictError) { + return { conflict: true, tempBranchName } + } + throw err + } +} + +async function exportChangesToGit({ + token, + projectId, + repoFullName, + lastSyncVersion, + currentVersion, + message, + baseCommit, +}) { + const diff = await HistoryManager.getProjectFileTreeDiff( + projectId, + lastSyncVersion, + currentVersion + ) + + const { paths } = await HistoryManager.getPathsAtVersion(projectId, lastSyncVersion) + const existedAtLastSync = new Set(paths) + + const deletePaths = new Set() + const upsertPaths = new Set() + + for (const item of diff) { + const oldPath = item.pathname + + if (item.operation === 'removed') { + // it can happen that removed file was created after last sync + if (existedAtLastSync.has(oldPath)) deletePaths.add(oldPath) + continue + } + + if (item.operation === 'renamed') { + if (existedAtLastSync.has(oldPath)) deletePaths.add(oldPath) + upsertPaths.add(item.newPathname) + continue + } + if (item.operation === 'added' || item.operation === 'edited') { + upsertPaths.add(oldPath) + } + } + // if a file is renamed and a new file with the old name is created, + // then the name will be in both sets, need to exclude it from deletePaths + for (const path of upsertPaths) { + if (deletePaths.has(path)) deletePaths.delete(path) + } + + // this can happen when a file is created and then removed + if (deletePaths.size === 0 && upsertPaths.size === 0) return null + + const limit = pLimit(api.maxConcurrency) + const upsertEntries = await Promise.all( + [...upsertPaths].map(path => + limit(async () => { + const buffer = await HistoryManager.getProjectFileBuffer(projectId, currentVersion, path) + const sha = await api.uploadBlob(token, repoFullName, buffer) + return { path, sha } + }) + ) + ) + + const deleteEntries = [...deletePaths].map(path => ({ path, sha: null })) + const entries = [...deleteEntries, ...upsertEntries] + + const newCommit = await createCommitFromEntries({ + token, + repoFullName, + parentCommit: baseCommit, + entries, + message, + }) + + return newCommit +} + +function generateBranchName() { + const d = new Date() + const pad = n => `${n}`.padStart(2, '0') + return `overleaf-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}` +} + +async function getGitBlobMap(token, repoFullName, commit) { + const blobEntries = await api.listBlobsAtCommit(token, repoFullName, commit) + return Object.fromEntries( + blobEntries.map(({ path, sha }) => [path, sha]) + ) +} + +function getEntryHash(entry) { + if (!entry?.data) return null + return ( + entry.data.hash || + gitBlobShaFromString(entry.data.content) + ) +} + +function gitBlobShaFromString(str) { + if (!str) return null + const content = Buffer.from(str, 'utf8') + const header = Buffer.from(`blob ${content.length}\0`, 'utf8') + const store = Buffer.concat([header, content]) + + return createHash('sha1').update(store).digest('hex') +} + +function validateFileName(name) { + if ( + typeof name !== 'string' || + name.length === 0 || + name.includes('\0') + ) return { name } + + const normalized = normalize(name) + + if ( + normalized !== name || + normalized.startsWith('/') || + normalized.startsWith('../') + ) return { name } + + return null +} diff --git a/services/web/modules/github-sync/app/src/GitSyncErrors.mjs b/services/web/modules/github-sync/app/src/GitSyncErrors.mjs new file mode 100644 index 0000000000..29c278af3e --- /dev/null +++ b/services/web/modules/github-sync/app/src/GitSyncErrors.mjs @@ -0,0 +1,26 @@ +import OError from '@overleaf/o-error' + +class GitNotLinkedError extends OError { + constructor(projectId) { + super('git server is not linked to project', { projectId }) + } +} + +class AlreadyExistsError extends OError {} +class GitConflictError extends OError {} +class InvalidTokenError extends OError {} +class NotFoundError extends OError {} +class PermissionDeniedError extends OError {} +class RateLimitError extends OError {} +class ProviderRequestError extends OError {} + +export { + AlreadyExistsError, + GitNotLinkedError, + GitConflictError, + InvalidTokenError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + ProviderRequestError, +} diff --git a/services/web/modules/github-sync/app/src/HistoryManager.mjs b/services/web/modules/github-sync/app/src/HistoryManager.mjs new file mode 100644 index 0000000000..b0ff41e692 --- /dev/null +++ b/services/web/modules/github-sync/app/src/HistoryManager.mjs @@ -0,0 +1,97 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import { fetchJson, fetchStream } from '@overleaf/fetch-utils' +import HistoryManager from '../../../../app/src/Features/History/HistoryManager.mjs' + +const urlBase = `${Settings.apis.project_history.url}/project` + +async function latestVersion(projectId) { + const url = `${urlBase}/${projectId}/version` + try { + await HistoryManager.promises.flushProject(projectId) + const json = await fetchJson(url) + return json.version + } catch (err) { + throw OError.tag(err, 'Failed to get the latest project version from history', { projectId }) + } +} + +async function getProjectSnapshot(projectId, version) { + const url = `${urlBase}/${projectId}/version/${version}` + try { + const snapshot = await fetchJson(url) + return snapshot.files || {} + } catch (err) { + throw OError.tag(err, 'Failed to get the project snapshot from history', { projectId }) + } +} + +async function getPathsAtVersion(projectId, version) { + const url = `${urlBase}/${projectId}/paths/version/${version}` + try { + const json = await fetchJson(url) + return json + } catch (err) { + throw OError.tag(err, 'Failed to fetch file paths from history', { projectId, version }) + } +} + +// response field 'diff' is an array of objects: +// { +// pathname: string, +// operation: 'added' | 'removed' | 'edited'| 'renamed' | undefined, +// ... +// } +async function getProjectFileTreeDiff(projectId, fromV, toV) { + const url = `${urlBase}/${projectId}/filetree/diff?from=${fromV}&to=${toV}` + try { + const json = await fetchJson(url) + return json.diff || [] + } catch (err) { + throw OError.tag(err, 'Failed to fetch filetree diff from history', { projectId, fromV, toV }) + } +} + +// return file from history as a base64 encoded string +async function getProjectFileBuffer(projectId, version, filePath) { + const url = `${urlBase}/${projectId}/version/${version}/${encodeURIComponent(filePath)}` + try { + const stream = await fetchStream(url) + return await _streamToBase64(stream) + } catch (err) { + throw OError.tag(err, 'Failed to fetch file from history', { projectId, version, filePath }) + } +} + +async function _streamToBase64(stream) { + const parts = [] + let leftover = null + + for await (const chunk of stream) { + const data = leftover ? Buffer.concat([leftover, chunk]) : chunk + + const remainder = data.length % 3 + const completeLength = data.length - remainder + + if (completeLength > 0) { + parts.push(data.subarray(0, completeLength).toString('base64')) + } + + leftover = remainder ? data.subarray(completeLength) : null + } + + if (leftover) { + parts.push(leftover.toString('base64')) + } + + return parts.join('') +} + +export default { + latestVersion, + getPathsAtVersion, + getProjectSnapshot, + getProjectFileBuffer, + getProjectFileTreeDiff, +} diff --git a/services/web/modules/github-sync/app/src/SyncStateManager.mjs b/services/web/modules/github-sync/app/src/SyncStateManager.mjs new file mode 100644 index 0000000000..e709798602 --- /dev/null +++ b/services/web/modules/github-sync/app/src/SyncStateManager.mjs @@ -0,0 +1,32 @@ +import { GitHubSyncProjectStates } from '../models/githubSyncProjectStates.mjs' +import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs' +const { normalizeQuery } = Mongo + +function getProjectState(projectId, projection = {}) { + return GitHubSyncProjectStates.findOne(normalizeQuery({ projectId }), projection).lean() +} + +function createProjectState(projectId, data) { + return GitHubSyncProjectStates.create({ + projectId: normalizeQuery(projectId), + ...data + }) +} + +function updateProjectState(projectId, data) { + return GitHubSyncProjectStates.updateOne( + normalizeQuery({ projectId }), + { $set: data }, + ) +} + +function removeProjectState(projectId) { + return GitHubSyncProjectStates.deleteMany(normalizeQuery({ projectId })) +} + +export default { + getProjectState, + createProjectState, + updateProjectState, + removeProjectState, +} diff --git a/services/web/modules/github-sync/app/src/TokenManager.mjs b/services/web/modules/github-sync/app/src/TokenManager.mjs new file mode 100644 index 0000000000..2f9006ed60 --- /dev/null +++ b/services/web/modules/github-sync/app/src/TokenManager.mjs @@ -0,0 +1,72 @@ +import logger from '@overleaf/logger' +import OError from '@overleaf/o-error' +import AccessTokenEncryptor from '@overleaf/access-token-encryptor' +import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs' +import { GitHubSyncUserCredentials } from '../models/githubSyncUserCredentials.mjs' +import { InvalidTokenError } from './GitSyncErrors.mjs' + +const { normalizeQuery } = Mongo + +// encrypt / decrypt helpers +const accessTokenEncryptor = new AccessTokenEncryptor({ + cipherLabel: process.env.GITHUB_TOKEN_CIPHER_LABEL || "OL_CEP-v3", + cipherPasswords: { + [process.env.GITHUB_TOKEN_CIPHER_LABEL || "OL_CEP-v3"]: process.env.GITHUB_TOKEN_CIPHER_PASSWORD, + }, +}) + +async function encryptAccessToken(accessToken) { + try { + return await accessTokenEncryptor.promises.encryptJson(accessToken) + } catch (err) { + throw OError.tag('failed to encrypt token', err) + } +} + +async function decryptAccessToken(tokenEncrypted) { + try { + return await accessTokenEncryptor.promises.decryptToJson(tokenEncrypted) + } catch (err) { + throw new InvalidTokenError('failed to decrypt token', { status: 401 }, err) + } +} + +// ------------------------- exports -------------------------- // +async function getUserToken(userId) { + const credentials = await GitHubSyncUserCredentials.findOne(normalizeQuery({ userId })) + if (!credentials) throw new InvalidTokenError('no user token', { userId, status: 400 }) + return await decryptAccessToken(credentials.github) +} + +async function saveUserToken(userId, accessToken) { + const tokenEncrypted = await encryptAccessToken(accessToken) + await GitHubSyncUserCredentials.findOneAndUpdate( + normalizeQuery({ userId }), + { $set: { github: tokenEncrypted } }, + { upsert: true } + ) +} + +// Try to revoke user's token, then remove it +async function removeUserToken(userId) { + let token + try { + const token = await getUserToken(userId) + } catch (err) { + logger.warn({ err, userId }, 'failed to get user token') + } + // fire-and-forget, but still handle errors + if (token) { + GitHubApiClient.revokeToken(token).catch(err => { + logger.warn({ err, userId }, 'failed to revoke user token') + }) + } + await GitHubSyncUserCredentials.deleteOne(normalizeQuery({ userId })) + return +} + +export default { + saveUserToken, + getUserToken, + removeUserToken +} diff --git a/services/web/modules/github-sync/frontend/js/components/github-integration-card.tsx b/services/web/modules/github-sync/frontend/js/components/github-integration-card.tsx new file mode 100644 index 0000000000..4667eaa698 --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/github-integration-card.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useProjectContext } from '@/shared/context/project-context' +import GithubLogo from '@/shared/svgs/github-logo' +import IntegrationCard from '@/features/integrations-panel/integration-card' +import GitSyncModal from './modals/git-sync-modal' +import { GitSyncModalStatus } from '../types/git-sync-types' + +const GitHubSyncCard = () => { + const { t } = useTranslation() + + const [showModal, setShowModal] = useState(false) + const { projectId, name } = useProjectContext() + const [modalStatus, setModalStatus] = useState('loading') + + return ( + <> + } + showPaywallBadge={false} + onClick={() => setShowModal(true)} + > + + { + setShowModal(false) + setModalStatus('loading') + }} + projectId={projectId} + projectName={name} + /> + + ) +} + +export default GitHubSyncCard diff --git a/services/web/modules/github-sync/frontend/js/components/github-sync-widget.tsx b/services/web/modules/github-sync/frontend/js/components/github-sync-widget.tsx new file mode 100644 index 0000000000..6e4635f5d7 --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/github-sync-widget.tsx @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import { getJSON, postJSON } 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 GithubLogo from '@/shared/svgs/github-logo' + +export const GitHubSyncWidget = function GitHubSyncWidget() { + const { t } = useTranslation() + const { appName } = getMeta('ol-ExposedSettings') + + const { + isLoading: isCheckingConn, + isError: isErrorConnCheck, + runAsync: runAsyncConnCheck, + data: isConnected, + setData: setConnState, + } = useAsync() + + const { + isLoading: isUnlinking, + isError: isErrorUnlink, + runAsync: runAsyncUnlink, + } = useAsync() + + const [showUnlinkModal, setShowUnlinkModal] = useState(false) + + const handleConnCheck = useCallback(() => { + runAsyncConnCheck(getJSON('/user/github-sync/status')).catch(err => + debugConsole.error(err?.data?.message || err?.message || err), + ) + }, [runAsyncConnCheck]) + + useEffect(() => { + handleConnCheck() + }, [handleConnCheck]) + + const handleUnlink = useCallback(() => { + runAsyncUnlink(postJSON('/user/github-sync/unlink')) + .then(() => setConnState(false)) + .catch(err => debugConsole.error(err?.data?.message || err?.message || err)) + .finally(() => setShowUnlinkModal(false)) + }, [runAsyncUnlink]) + + if (isCheckingConn) { + return ( +
+
+ +
+ +
+
+

GitHub

+
+ +

+ {t('loading')}… +

+
+
+ ) + } + + return ( + <> +
+
+ +
+ +
+
+

GitHub

+
+ +

+ {t('github_sync_description', { appName })} +

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

+ {t('unlink_github_warning', { + provider: 'GitHub', + })} +

+
+ + + setShowUnlinkModal(false)} + > + {t('cancel')} + + + + {isUnlinking ? t('unlinking') : t('unlink')} + + +
+ + ) +} diff --git a/services/web/modules/github-sync/frontend/js/components/import-from-github-menu.tsx b/services/web/modules/github-sync/frontend/js/components/import-from-github-menu.tsx new file mode 100644 index 0000000000..47f6afb647 --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/import-from-github-menu.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu' + +export default function ImportFromGitHubMenu({ onClick }: { onClick: (e: React.MouseEvent) => void }) { + const { t } = useTranslation() + return ( + + {t('import_from_github')} + + ) +} \ No newline at end of file diff --git a/services/web/modules/github-sync/frontend/js/components/import-from-github-modal-wrapper.tsx b/services/web/modules/github-sync/frontend/js/components/import-from-github-modal-wrapper.tsx new file mode 100644 index 0000000000..ab474b82f9 --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/import-from-github-modal-wrapper.tsx @@ -0,0 +1,218 @@ +import React, { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import useAsync from '@/shared/hooks/use-async' +import { debugConsole } from '@/utils/debugging' +import { + postJSON, + getJSON, +} from '@/infrastructure/fetch-json' +import getMeta from '@/utils/meta' +import { + OLModal, + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' +import OLSpinner from '@/shared/components/ol/ol-spinner' +import OLNotification from '@/shared/components/ol/ol-notification' + +type GitSyncRepo = { + name: string + fullName: string + defaultBranchName: string +} + +type GitSyncReposResponse = { + repos?: GitSyncRepo[] +} + +function ImportFromGitHubModalContent({ handleHide }: { handleHide: () => void }) { + const { t } = useTranslation() + const { appName } = getMeta('ol-ExposedSettings') + + const { + isLoading, + isSuccess, + isError, + data, + runAsync, + } = useAsync() + + useEffect(() => { + runAsync(getJSON('/user/github-sync/repos')) + .catch(err => debugConsole.error(err?.data?.message || err?.message || err)) + }, [runAsync]) + + const reposExist = data?.repos != null + const repos = reposExist ? data.repos : [] + + const { + isLoading: isImporting, + isSuccess: isImported, + isError: isErrorImport, + runAsync: runAsyncImport, + } = useAsync<{ projectId: string }>() + + useEffect(() => { + if (isImported) { + handleHide() + } + }, [handleHide, isImported]) + + const showLinkToGitHub = !isImporting && isSuccess && !reposExist + const showRepos = !isImporting && isSuccess && reposExist + + const handleImport = (repo: GitSyncRepo) => { + + runAsyncImport( + postJSON('/project/new/github-sync', { + body: repo + }) + ) + .then(data => { + window.location.href = `/project/${data.projectId}` + }) + .catch(err => debugConsole.error(err?.data?.message || err?.message || err)) + } + + return ( + <> + + {t('import_from_github')} + + + + {isLoading && ( + + + {t('loading_github_repositories')} + + )} + + {isImporting && ( + + + {t('importing')} + + )} + + {isError && ( +
+ +
+ )} + + {showLinkToGitHub && ( +
+

+ {t('link_to_github_description', { appName })} +

+ + {t('link_to_github')} + +
+ )} + + {showRepos && ( +
+ {repos.length === 0 ? ( +

+ {t('you_dont_have_any_repositories')} +

+ ) : ( + <> +

+ {t('select_github_repository', { appName })} +

+
+ + + {repos.map(repo => ( + + + + + ))} + +
+ {repo.name} + + + handleImport(repo)} + > + {t('import_to_sharelatex', { appName: 'Overleaf' })} + +
+
+ + )} +
+ )} + + {isErrorImport && ( +
+ +
+ )} + +
+ + + {!isImporting && ( + + + {t('cant_see_what_youre_looking_for_question')} + + + )} + + {t('cancel')} + + + + ) +} + +export default function ImportFromGitHubModal({ onHide }: { onHide: () => void }) { + return ( + + + + ) +} diff --git a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-cannot-export-modal.tsx b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-cannot-export-modal.tsx new file mode 100644 index 0000000000..6cd4f312dd --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-cannot-export-modal.tsx @@ -0,0 +1,47 @@ +import { useTranslation, Trans } from 'react-i18next' +import OLNotification from '@/shared/components/ol/ol-notification' +import { + OLModalBody, + OLModalFooter, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' +import { ProjectSyncState } from '../../types/git-sync-types' + +type GitSyncNeedPermissionModalProps = { + projectSyncState: ProjectSyncState + handleHide: () => void +} + +const GitSyncCannotExportModal = ({ projectSyncState, handleHide }: GitSyncCannotExportModalProps) => { + const { t } = useTranslation() + return ( + <> + + : <> + ]} + /> + )} + /> + + + + {t('close')} + + + + ) +} + +export default GitSyncCannotExportModal diff --git a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-confirm-unlink-modal.tsx b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-confirm-unlink-modal.tsx new file mode 100644 index 0000000000..17637858f4 --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-confirm-unlink-modal.tsx @@ -0,0 +1,101 @@ +import { useTranslation, Trans } from 'react-i18next' +import useAsync from '@/shared/hooks/use-async' +import { deleteJSON } from '@/infrastructure/fetch-json' +import { 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 { debugConsole } from '@/utils/debugging' +import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types' + +type GitSyncUnlinkModalProps = { + handleHide: () => void + setModalStatus: (modalStatus: GitSyncModalStatus) => void + projectSyncState: ProjectSyncState + projectId: string +} + +const GitSyncUnlinkModal = ({ + handleHide, + setModalStatus, + projectSyncState, + projectId, +}: GitSyncInitModalProps) => { + const { t } = useTranslation() + + const { + error, + isLoading, + runAsync, + } = useAsync() + + const handleUnlink = () => { + runAsync(deleteJSON(`/project/${projectId}/github-sync`)) + .then(() => setModalStatus('need-export')) + .catch(err => debugConsole.error(err?.data?.message || err?.message || err)) + } + + return ( + <> + +

+ {t('project_linked_to')}:  + + {projectSyncState.repoFullName} + +

+ +
+ + {!error ? ( + + ) : ( + : <> + ]} + /> + ) : ( + t('something_went_wrong_server') + ) + } + /> + )} +
+ + + +
+ + {t('confirm')} + + + + {t('cancel')} + +
+
+ + ) +} + +export default GitSyncUnlinkModal diff --git a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-conflict-modal.tsx b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-conflict-modal.tsx new file mode 100644 index 0000000000..600777ec4a --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-conflict-modal.tsx @@ -0,0 +1,57 @@ +import { useTranslation, Trans } from 'react-i18next' +import getMeta from '@/utils/meta' +import { + 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 GitSyncConflictModalProps = { + projectSyncState: ProjectSyncState + handleHide: () => void + setModalStatus: (modalStatus: GitSyncModalStatus) => void +} + +const GitSyncConflictModal = ({ projectSyncState, handleHide, setModalStatus }: GitSyncConflictModalProps) => { + const { t } = useTranslation() + const { appName } = getMeta('ol-ExposedSettings') + + return ( + <> + + +

+ ]} + /> +

+
+ + + {t('close')} + + setModalStatus('run-merge-resolved') + } + > + {t('continue_github_merge')} + + + + ) +} + +export default GitSyncConflictModal diff --git a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-export-modal.tsx b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-export-modal.tsx new file mode 100644 index 0000000000..3c29533e22 --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-export-modal.tsx @@ -0,0 +1,229 @@ +import { useTranslation } from 'react-i18next' +import { useState, useEffect } from 'react' +import useAsync from '@/shared/hooks/use-async' +import { debugConsole } from '@/utils/debugging' +import { + getJSON, + postJSON +} from '@/infrastructure/fetch-json' +import { + OLModalBody, + OLModalFooter, +} from '@/shared/components/ol/ol-modal' +import OLNotification from '@/shared/components/ol/ol-notification' +import OLButton from '@/shared/components/ol/ol-button' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import OLFormSelect from '@/shared/components/ol/ol-form-select' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import { GitSyncModalStatus } from '../../types/git-sync-types' + +type OrgsResponse = { + user: string + orgs: string[] +} + +type GitSyncExportModalProps = { + projectId: string + projectName: string + handleHide: () => void + setModalStatus: (modalStatus: GitSyncModalStatus) => void +} + +const GitSyncExportModal = ({ + projectId, + projectName, + handleHide, + setModalStatus +}: GitSyncExportModalProps) => { + const { t } = useTranslation() + + const [selectedOwner, setSelectedOwner] = useState('') + const [errorMessage, setErrorMessage] = useState('') + const [repoName, setRepoName] = useState(projectName) + const [description, setDescription] = useState('') + const [visibility, setVisibility] = useState<'public' | 'private'>('private') + + const { + runAsync: runAsyncUserAndOrgs, + data: userAndOrgs, + error: errorUserAndOrgs + } = useAsync() + + useEffect(() => { + runAsyncUserAndOrgs(getJSON('/user/github-sync/orgs')) + .then(userAndOrgs => setSelectedOwner(userAndOrgs?.user)) + .catch(err => debugConsole.error(err?.data?.message || err?.message || err)) + }, []) + + const { isLoading, error, setError, runAsync } = useAsync() + + const createRepo = () => { + const isPublic = visibility === 'public' + const org = selectedOwner === userAndOrgs?.user ? undefined : selectedOwner + + runAsync(postJSON(`/project/${projectId}/github-sync/export`, { + body: { + name: repoName, + description, + isPublic, + org, + }, + })) + .then(() => setModalStatus('loading')) + .catch(err => { + debugConsole.error(err?.data?.message || err?.message || err) + if (!err?.data?.key) setError(t('something_went_wrong_server')) + else setError(t(err.data.key)) + }) + } + + return ( + <> + +

{t('export_project_to_github')}

+

{t('project_not_linked_to_github')}

+ + {error && ( + + )} + + {errorUserAndOrgs && ( + + )} + + + + + + + {selectedOwner === userAndOrgs?.user + ? t('owner') + : t('organization')} + + setSelectedOwner(e.target.value)} + > + + {userAndOrgs?.orgs.map(org => ( + + ))} + + + + + + + + {t('repository_name')} + + setRepoName(e.target.value)} + /> + + + + + + + + + {t('description')} ({t('optional')}) + + setDescription(e.target.value)} + /> + + + + +
+ +
+ + {t('repository_visibility')} + + + + + + setVisibility('public')} + label={t('public', { defaultValue: 'Public' })} + description={t('github_public_description')} + /> + + + + + + setVisibility('private')} + label={t('private', { defaultValue: 'Private' })} + description={t('github_private_description')} + /> + + + +
+
+
+ + + + {t('cancel')} + + + + {t('create_project_in_github')} + + + + ) +} + +export default GitSyncExportModal diff --git a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-loading-modal.tsx b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-loading-modal.tsx new file mode 100644 index 0000000000..22591c3233 --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-loading-modal.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from 'react-i18next' +import { + 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 { GitSyncModalStatus } from '../../types/git-sync-types' + +type GitSyncLoadingModalProps = { + handleHide: () => void + setModalStatus: (modalStatus: GitSyncModalStatus) => void + errorMessage: string | null +} +const GitSyncLoadingModal = ({ handleHide, setModalStatus, errorMessage }: GitSyncLoadingModalProps) => { + const { t } = useTranslation() + + return ( + <> + +
+ + {t('checking_project_github_status')} +
+
+ + {errorMessage && ( + + )} + + + {errorMessage && ( + setModalStatus('confirm-unlink')} + > + {t('unlink')} + + )} + + {t('cancel')} + + + + ) +} + +export default GitSyncLoadingModal diff --git a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-merge-modal.tsx b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-merge-modal.tsx new file mode 100644 index 0000000000..98f80f0438 --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-merge-modal.tsx @@ -0,0 +1,119 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { debugConsole } from '@/utils/debugging' +import { postJSON } from '@/infrastructure/fetch-json' +import useAsync from '@/shared/hooks/use-async' +import { + 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 { GitSyncModalStatus, ProjectSyncState } from '../../types/git-sync-types' + +type GitSyncMergeModalProps = { + handleHide: () => void + modalStatus: GitSyncModalStatus + setModalStatus: (modalStatus: GitSyncModalStatus) => void + setProjectSyncState: (projectSyncState: ProjectSyncState) => void + commitMessage: string + setCommitMessage: (message: string) => void + projectId: string +} + +const GitSyncMergeModal = ({ + handleHide, + modalStatus, + setModalStatus, + setProjectSyncState, + commitMessage, + setCommitMessage, + projectId +}: GitSynMergeModalProps) => { + + const { t } = useTranslation() + const { error, isError, isLoading, setError, runAsync } = useAsync() + + useEffect(() => { + if (modalStatus !== 'run-merge' && + modalStatus !== 'run-merge-resolved' + ) return + + const claimConflictIsResolved = (modalStatus === 'run-merge-resolved') + + runAsync( + postJSON(`/project/${projectId}/github-sync/merge`, { + body: { message: commitMessage, claimConflictIsResolved }, + }) + ) + .then(data => { + switch (data.mergeStatus) { + + case 'clean': + setModalStatus('merge-overview') + break + + case 'conflict': + setModalStatus('show-conflict') + break + + default: + const message = `Unexpected merge status received: ${data.mergeStatus}` + const msError = new Error(message) + setError(msError) + throw msError + } + setProjectSyncState(data) + setCommitMessage('') + }) + .catch(err => { + debugConsole.error(err?.data?.message || err?.message || err) + if (err?.info?.statusCode === 403 || err?.info?.statusCode === 404) setModalStatus('loading') + }) + + }, [ + modalStatus, + projectId, + commitMessage, + runAsync, + ]) + + if (!isLoading && !isError) return + + return ( + <> + +
+ + + {t('importing_and_merging_changes_in_github')} +
+
+ + {error && ( + + )} + + + + {t('close')} + + + + ) +} + +export default GitSyncMergeModal diff --git a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-merge-overview-modal.tsx b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-merge-overview-modal.tsx new file mode 100644 index 0000000000..2f14b8f8de --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-merge-overview-modal.tsx @@ -0,0 +1,238 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import useAsync from '@/shared/hooks/use-async' +import { getJSON } from '@/infrastructure/fetch-json' +import { OLModalBody, OLModalFooter } from '@/shared/components/ol/ol-modal' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLButton from '@/shared/components/ol/ol-button' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import OLNotification from '@/shared/components/ol/ol-notification' +import { debugConsole } from '@/utils/debugging' +import { ProjectSyncState, GitSyncModalStatus } from '../../types/git-sync-types' + +type GitSyncMergeOverviewModalProps = { + handleHide: () => void + setModalStatus: (modalStatus: GitSyncModalStatus) => void + projectSyncState: ProjectSyncState + projectId: string + commitMessage: string + setCommitMessage: (message: string) => void + +} + +type GitCommit = { + message: string + author: { + name: string + email: string + date: string + } + sha: string +} + +type UnmergedCommitsResponse = { + commits: GitCommit[] + diverged: boolean + isProjectUpdated: boolean +} + +const GitSyncMergeOverviewModal = ({ + handleHide, + setModalStatus, + projectSyncState, + projectId, + commitMessage, + setCommitMessage +}: GitSyncMergeOverviewModalProps) => { + const { t } = useTranslation() + const appName = 'Overleaf' + + const { + data, + setData, + error, + isSuccess, + isLoading, + runAsync, + } = useAsync() + + const loadUnmergedCommits = () => { + setData({}) + + runAsync(getJSON(`/project/${projectId}/github-sync/merge/overview`)) + .then(data => { if (!data) setModalStatus('loading') }) + .catch(err => { + debugConsole.error(err?.data?.message || err?.message || err) + if (err?.info?.statusCode === 403 || + err?.info?.statusCode === 404 || + err?.info?.statusCode === 401 + ) setModalStatus('loading') + }) + } + + useEffect(() => { + loadUnmergedCommits() + }, [projectId, runAsync]) + + return ( + <> + +

+ {t('project_linked_to')}:  + + {projectSyncState.repoFullName} + +

+ + {isLoading && ( + <> +
+ + {t('loading')} +
+ + )} + + {isSuccess && ( + <> +
+ + {data.diverged && ( + + )} + +

+ {t('recent_commits_in_github')} + + +

+ + {data.commits?.length === 0 && ( +
+

{t('no_new_commits_in_github')}

+
+ )} + + {data.commits?.length > 0 && ( +
+ {data.commits.map((commit: GitCommit) => ( +
+ + + {commit.sha.substring(0, 7)} + + + + + {commit.message} + + +
+ by {commit.author.name} <{commit.author.email}> +
+
+ ))} +
+ )} + + {data.isProjectUpdated && ( + <> +
+ +

{t('sync_project_to_github_explanation', { appName })}

+ + + + setCommitMessage(e.target.value)} + /> + + + +
+ + )} + + )} + + {error && ( + + )} + +
+ + +
+ +
+ setModalStatus('confirm-unlink')} + disabled={!isSuccess && error?.info?.statusCode !== 404} + > + {t('unlink')} + +
+ +
+ setModalStatus('run-merge')} + disabled={!isSuccess} + > + {t('sync')} + + + + {t('close')} + +
+ +
+
+ + ) +} + +export default GitSyncMergeOverviewModal diff --git a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-modal.tsx b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-modal.tsx new file mode 100644 index 0000000000..6892cc6230 --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-modal.tsx @@ -0,0 +1,226 @@ +import { useTranslation } from 'react-i18next' +import { useState, useEffect } from 'react' +import { + OLModalHeader, + OLModalTitle, + OLModal, +} from '@/shared/components/ol/ol-modal' + +import { getJSON } from '@/infrastructure/fetch-json' +import useAsync from '@/shared/hooks/use-async' +import { debugConsole } from '@/utils/debugging' + +import GitSyncLoadingModal from './git-sync-loading-modal' +import GitSyncMergeOverviewModal from './git-sync-merge-overview-modal' +import GitSyncMergeModal from './git-sync-merge-modal' +import GitSyncConflictModal from './git-sync-conflict-modal' +import GitSyncExportModal from './git-sync-export-modal' +import GitSyncNeedAuthModal from './git-sync-need-auth-modal' +import GitSyncNeedPermissionModal from './git-sync-need-permission-modal' +import GitSyncConfirmUnlinkModal from './git-sync-confirm-unlink-modal' +import GitSyncUnlinkUnavailableModal from './git-sync-unlink-unavailable-modal' +import GitSyncCannotExportModal from './git-sync-cannot-export-modal' + +import { GitSyncModalStatus, ProjectSyncState } from '../../types/git-sync-types' + +type GitSyncModalProps = { + show: boolean + handleHide: () => void + projectId: string + projectName: string + modalStatus: GitSyncModalStatus + setModalStatus: (modalStatus: GitSyncModalStatus) => void +} + +function GitSyncModal({ + show, + handleHide, + projectId, + projectName, + modalStatus, + setModalStatus, +}: GitSyncModalProps) { + + const { t } = useTranslation() + const [commitMessage, setCommitMessage] = useState('') + const [errorMessage, setErrorMessage] = useState('') + +const { + runAsync: runAsyncConn, + error: errorConn, + setError: setErrorConn, + data: dataConn, +} = useAsync() + +const { + runAsync, + data: projectSyncState, + setData: setProjectSyncState, + error, + setError, +} = useAsync() + + useEffect(() => { + if (!show) return + if (modalStatus !== 'loading') return + + const fetchProjectState = () => { + setError(null) + setErrorMessage('') + + runAsync(getJSON(`/project/${projectId}/github-sync/state`)) + .then(pss => { + switch (pss.mergeStatus) { + // pss.ownerEmail is set to the email address of the project owner + // or null if the user is the project owner + // only owner can export project to git server + case 'need-export': + if (!pss.ownerEmail) setModalStatus('need-export') + else setModalStatus('cannot-export') + break + + case 'clean': + case 'diverged': + setModalStatus('merge-overview') + break + + case 'conflict': + setModalStatus('show-conflict') + break + + // user does not have push permission to repo + // pss.ownerEmail is set to the email address of the owner + // or null if the user is the project owner + // if the user is owner, it seems that repo is not accessible + case 'need-permission': { + if (!pss.ownerEmail) setModalStatus('unlink-unavailable') + else setModalStatus('need-permission') + break + } + } + }) + .catch(err => { + debugConsole.error(err?.data?.message || err?.message || err) + setErrorMessage(t('generic_something_went_wrong')) + }) + } + + const fetchConnectionStatus = () => { + setErrorConn(null) + setErrorMessage('') + + runAsyncConn(getJSON(`/user/github-sync/status`)) + .then(isConnected => { + if (!isConnected) { + setModalStatus('need-auth') + return + } + fetchProjectState() + }) + .catch(err => { + debugConsole.error(err?.data?.message || err?.message || err) + setErrorMessage(t('generic_something_went_wrong')) + }) + } + + fetchConnectionStatus() + + }, [show, modalStatus, projectId, runAsyncConn, runAsync]) + + return ( + + + {t('sync_with_github')} + + + {modalStatus === 'loading' && ( + + )} + + {modalStatus === 'need-export' && ( + + )} + + {modalStatus === 'cannot-export' && ( + + )} + + {modalStatus === 'need-auth' && ( + + )} + + {modalStatus === 'need-permission' && ( + + )} + + {modalStatus === 'merge-overview' && ( + + )} + + {modalStatus === 'confirm-unlink' && ( + + )} + + {modalStatus === 'unlink-unavailable' && ( + + )} + + {(modalStatus === 'run-merge' || + modalStatus === 'run-merge-resolved') && ( + + )} + + {modalStatus === 'show-conflict' && ( + + )} + + + ) +} + +export default GitSyncModal diff --git a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-need-auth-modal.tsx b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-need-auth-modal.tsx new file mode 100644 index 0000000000..8eaf2cab9b --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-need-auth-modal.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import { + OLModalBody, + OLModalFooter, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' + +const GitSyncNeedAuthModal = ({ handleHide }: { handleHide: () => void }) => { + const { t } = useTranslation() + const { appName } = getMeta('ol-ExposedSettings') + return ( + <> + +

{t('link_to_github_description', { appName })}

+
+ + + {t('close')} + + + { + handleHide() + window.open( + '/user/github-sync/oauth2', + 'githubAuth', + 'width=600,height=700' + ) + }} + > + {t('link_to_github')} + + + + ) +} + +export default GitSyncNeedAuthModal diff --git a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-need-permission-modal.tsx b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-need-permission-modal.tsx new file mode 100644 index 0000000000..080863c0cc --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-need-permission-modal.tsx @@ -0,0 +1,57 @@ +import { useTranslation, Trans } from 'react-i18next' +import OLNotification from '@/shared/components/ol/ol-notification' +import { + OLModalBody, + OLModalFooter, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' +import { ProjectSyncState } from '../../types/git-sync-types' + +type GitSyncNeedPermissionModalProps = { + projectSyncState: ProjectSyncState + handleHide: () => void +} + +const GitSyncNeedPermissionModal = ({ projectSyncState, handleHide }: GitSyncConflictModalProps) => { + const { t } = useTranslation() + return ( + <> + + + ) : ( + <> + ), + projectSyncState.ownerEmail ? : <> + ]} + /> + )} + /> + + + + {t('close')} + + + + ) +} + +export default GitSyncNeedPermissionModal diff --git a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-unlink-unavailable-modal.tsx b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-unlink-unavailable-modal.tsx new file mode 100644 index 0000000000..7fe4b56d45 --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-unlink-unavailable-modal.tsx @@ -0,0 +1,85 @@ +import { useTranslation, Trans } from 'react-i18next' +import useAsync from '@/shared/hooks/use-async' +import { deleteJSON } from '@/infrastructure/fetch-json' +import { 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 { debugConsole } from '@/utils/debugging' +import { GitSyncModalStatus } from '../../types/git-sync-types' + +type GitSyncUnlinkUnavailableModalProps = { + handleHide: () => void + setModalStatus: (modalStatus: GitSyncModalStatus) => void + projectId: string +} + +const GitSyncUnlinkUnavailableModal = ({ + handleHide, + setModalStatus, + projectId, +}: GitSyncInitModalProps) => { + const { t } = useTranslation() + + const { + error, + isLoading, + runAsync, + } = useAsync() + + const handleUnlink = () => { + runAsync(deleteJSON(`/project/${projectId}/github-sync`)) + .then(() => setModalStatus('need-export')) + .catch(err => debugConsole.error(err?.data?.message || err?.message || err)) + } + + return ( + <> + + + + {error && ( + : <> + ]} + /> + ) : ( + t('something_went_wrong_server') + ) + } + /> + )} + + + +
+ + {t('unlink_github_repository')} + + + + {t('cancel')} + +
+
+ + ) +} + +export default GitSyncUnlinkUnavailableModal diff --git a/services/web/modules/github-sync/frontend/js/types/git-sync-types.ts b/services/web/modules/github-sync/frontend/js/types/git-sync-types.ts new file mode 100644 index 0000000000..325cee8424 --- /dev/null +++ b/services/web/modules/github-sync/frontend/js/types/git-sync-types.ts @@ -0,0 +1,19 @@ +export type GitSyncModalStatus = + 'loading' + | 'need-export' + | 'cannot-export' + | 'need-auth' + | 'need-permission' + | 'merge-overview' + | 'confirm-unlink' + | 'unlink-unavailable' + | 'run-merge' + | 'run-merge-resolved' + | 'show-conflict' + +export type ProjectSyncState = { + mergeStatus: 'clean' | 'conflict' | 'diverged' | 'need-export' | 'need-permission' + repoFullName: string + unmergedBranchName: string | null + ownerEmail?: string +} diff --git a/services/web/modules/github-sync/frontend/styles/github-sync.scss b/services/web/modules/github-sync/frontend/styles/github-sync.scss new file mode 100644 index 0000000000..c8af47f980 --- /dev/null +++ b/services/web/modules/github-sync/frontend/styles/github-sync.scss @@ -0,0 +1,8 @@ +.github-sync-commits-heading { + align-items: center; + display: flex; + font-size: var(--font-size-03); + font-weight: 600; + justify-content: space-between; + line-height: var(--line-height-03); +} diff --git a/services/web/modules/github-sync/index.mjs b/services/web/modules/github-sync/index.mjs new file mode 100644 index 0000000000..20f79cc899 --- /dev/null +++ b/services/web/modules/github-sync/index.mjs @@ -0,0 +1,55 @@ +import Settings from '@overleaf/settings' +import Modules from '../../app/src/infrastructure/Modules.mjs' +import logger from '@overleaf/logger' +import GitHubSyncRouter from './app/src/GitHubSyncRouter.mjs' +import SyncStateManager from './app/src/SyncStateManager.mjs' +import TokenManager from './app/src/TokenManager.mjs' + +let GitHubSyncModule = {} +if (process.env.GITHUB_SYNC_ENABLED?.toLowerCase() === 'true') { + logger.debug({}, 'Enabling GitHub Sync module') +/* + const [{ default: GitHubSyncRouter }, + { default: SyncStateManager }, + { default: TokenManager } + ] = + await Promise.all([ + import('./app/src/GitHubSyncRouter.mjs'), + import('./app/src/SyncStateManager.mjs'), + import('./app/src/TokenManager.mjs'), + ]) +*/ + + // Delete project sync state from mongo (hook 'projectExpired') + + const siteUrl = process.env.OVERLEAF_SITE_URL?.replace(/\/+$/, '') || 'http://localhost' + Settings.githubSync = { + clientID: process.env.GITHUB_SYNC_CLIENT_ID, + clientSecret: process.env.GITHUB_SYNC_CLIENT_SECRET, + callbackURL: `${siteUrl}/user/github-sync/oauth2/callback`, + }, + + Modules.hooks.attach('projectExpired', async projectId => { + try { + await SyncStateManager.removeProjectState(projectId) + logger.debug({ projectId }, 'on project expire: removed Git sync state') + } catch (err) { + logger.warn({ projectId, err }, 'on project expire: failed to remove Git sync state') + } + }) + + // Delete user github token from mongo (hook 'expireDeletedUser') + Modules.hooks.attach('expireDeletedUser', async userId => { + try { + await TokenManager.removeUserToken(userId) + } catch (err) { + logger.warn({ userId, err }, 'on user expire: failed removing user token') + } + }) + + GitHubSyncModule = { + router: GitHubSyncRouter, + } +} + +export default GitHubSyncModule