Github Sync: initial commit

This commit is contained in:
yu-i-i
2026-09-22 02:41:42 +02:00
parent ac73b428ff
commit de2744f834
36 changed files with 3969 additions and 10 deletions
+23 -3
View File
@@ -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: {},
@@ -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": "",
@@ -251,7 +251,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
}
if (
update.meta.type === 'external' &&
update.meta.source === 'git-bridge'
(update.meta.source === 'git-bridge' || update.meta.source === 'github')
) {
return
}
@@ -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';
+7 -2
View File
@@ -198,6 +198,8 @@
"as_a_member_of_sso_required": "Als Mitglied von <b>__institutionName__</b> musst du dich über dein institutionelles Portal bei <b>__appName__</b> 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__</0>) zu pushen. Bitte den Projektinhaber (<1>__projectOwnerEmail__</1>), dich als Kollaborator zu diesem Repository hinzuzufügen.",
"ask_proj_owner_to_export_to_github": "Bitte den Projektinhaber (<0>__projectOwnerEmail__</0>), 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__</0>), 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__</0>), 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__</0> 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",
+8 -3
View File
@@ -252,7 +252,9 @@
"as_a_member_of_sso_required": "As a member of <b>__institutionName__</b>, you must log in to <b>__appName__</b> 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__</0>) 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__</0>. Ask the owner of the project (<1>__projectOwnerEmail__</1>) to add you as a collaborator to this repository.",
"ask_proj_owner_to_export_to_github": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) 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__</0>) 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__</0>) to reconnect the project.",
"ask_repo_owner_to_renew_overleaf_subscription": "Ask the GitHub repository owner (<0>__repoOwnerEmail__</0>) 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__</0>. 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.",
+13 -1
View File
@@ -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": "Вы уверены, что хотите отменить расширение <strong>__addOnName__</strong>?",
"ask_proj_owner_to_add_you_as_github_collaborator": "У вас нет прав на запись в подключенный репозиторий (<0>__repoFullName__</0>). Попросите владельца проекта (<1>__projectOwnerEmail__</1>) добавить вас в качестве коллаборатора в этот Git-репозиторий.",
"ask_proj_owner_to_export_to_github": "Попросите владельца проекта (<0>__projectOwnerEmail__</0>) экспортировать проект в Git-репозиторий и добавить вас в качестве коллаборатора.",
"at_most_x_libraries_can_be_selected": "Можно выбрать не более __count__ библиотек",
"audit_logs": "Журналы аудита",
"ask_proj_owner_to_unlink_from_current_github": "Попросите владельца проекта (<0>projectOwnerEmail</0>) отключить проект от текущего Git-репозитория и создать подключение к другому репозиторию.",
"auto_complete": "Автодополнение",
"autocomplete": "Автозавершение",
"autocomplete_references": "Автодополнение ссылок (внутри блока <code>\\cite{}</code>)",
@@ -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__</0> с веткой по умолчанию в 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__</0> из <1>__groupSize__</1> доступных участников",
"you_unpaused_your_subscription": "Вы возобновили подписку.",
"youll_be_moved_to_the_free_plan": "Вы будете переведены на бесплатный тариф",
@@ -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,
)
@@ -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,
)
@@ -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,
}
@@ -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),
}
@@ -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,
}
@@ -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
)
},
}
@@ -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
}
@@ -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,
}
@@ -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,
}
@@ -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,
}
@@ -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
}
@@ -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<GitSyncModalStatus>('loading')
return (
<>
<IntegrationCard
title={t('github')}
description={t('sync_with_a_github_repository')}
icon={<GithubLogo size={32} />}
showPaywallBadge={false}
onClick={() => setShowModal(true)}
>
</IntegrationCard>
<GitSyncModal
show={showModal}
modalStatus={modalStatus}
setModalStatus={setModalStatus}
handleHide={() => {
setShowModal(false)
setModalStatus('loading')
}}
projectId={projectId}
projectName={name}
/>
</>
)
}
export default GitHubSyncCard
@@ -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<boolean>()
const {
isLoading: isUnlinking,
isError: isErrorUnlink,
runAsync: runAsyncUnlink,
} = useAsync<void>()
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 (
<div className="settings-widget-container">
<div>
<GithubLogo />
</div>
<div className="description-container">
<div className="title-row">
<h4>GitHub</h4>
</div>
<p className="small">
<span>{t('loading')}…</span>
</p>
</div>
</div>
)
}
return (
<>
<div className="settings-widget-container">
<div>
<GithubLogo size={40} />
</div>
<div className="description-container">
<div className="title-row">
<h4 id="github-sync">GitHub</h4>
</div>
<p className="small">
{t('github_sync_description', { appName })}
</p>
{isErrorConnCheck && (
<OLNotification
type="error"
content={t('github_sync_error')}
/>
)}
{isErrorUnlink && (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</div>
<div>
{isConnected ? (
<OLButton
variant="danger-ghost"
onClick={() => setShowUnlinkModal(true)}
disabled={isUnlinking}
>
{isUnlinking ? t('unlinking') : t('unlink')}
</OLButton>
) : isErrorConnCheck ? (
<OLButton
variant="secondary"
onClick={handleConnCheck}
>
{t('reconnect')}
</OLButton>
) : (
<OLButton
variant="secondary"
href="/user/github-sync/oauth2"
>
{t('link')}
</OLButton>
)}
</div>
</div>
<OLModal
id="git-sync-modal"
show={showUnlinkModal}
onHide={() => setShowUnlinkModal(false)}
backdrop="static"
>
<OLModalHeader>
<OLModalTitle>
{t('unlink_provider_account_title', {
provider: 'GitHub',
})}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
{t('unlink_github_warning', {
provider: 'GitHub',
})}
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={() => setShowUnlinkModal(false)}
>
{t('cancel')}
</OLButton>
<OLButton
variant="danger-ghost"
onClick={handleUnlink}
disabled={isUnlinking}
>
{isUnlinking ? t('unlinking') : t('unlink')}
</OLButton>
</OLModalFooter>
</OLModal>
</>
)
}
@@ -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 (
<DropdownItem onClick={onClick}>
{t('import_from_github')}
</DropdownItem>
)
}
@@ -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<GitSyncReposResponse>()
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 (
<>
<OLModalHeader onClose={handleHide}>
<OLModalTitle>{t('import_from_github')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{isLoading && (
<span>
<OLSpinner size="sm" className="me-2"/>
{t('loading_github_repositories')}
</span>
)}
{isImporting && (
<span>
<OLSpinner size="sm" className="me-2"/>
{t('importing')}
</span>
)}
{isError && (
<div className="notification-list">
<OLNotification
type="error"
content={t('something_went_wrong_server')}
/>
</div>
)}
{showLinkToGitHub && (
<div className="text-center">
<p>
{t('link_to_github_description', { appName })}
</p>
<OLButton
variant="secondary"
href="/user/github-sync/oauth2"
>
{t('link_to_github')}
</OLButton>
</div>
)}
{showRepos && (
<div>
{repos.length === 0 ? (
<p className="text-center">
{t('you_dont_have_any_repositories')}
</p>
) : (
<>
<p className="text-center">
{t('select_github_repository', { appName })}
</p>
<div className="table-container table-container-bordered">
<table className="table table-striped table-hover">
<tbody>
{repos.map(repo => (
<tr key={repo.fullName}>
<td>
{repo.name}
<div className="small">
<a
href={`https://github.com/${repo.fullName}`}
target="_blank"
rel="noopener noreferrer"
>
{repo.fullName}
</a>
</div>
</td>
<td className="text-end">
<OLButton
variant="primary"
onClick={() => handleImport(repo)}
>
{t('import_to_sharelatex', { appName: 'Overleaf' })}
</OLButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)}
{isErrorImport && (
<div className="notification-list">
<OLNotification
type="error"
content={t('something_went_wrong_server')}
/>
</div>
)}
</OLModalBody>
<OLModalFooter>
{!isImporting && (
<span className="me-auto">
<a
href="https://help.github.com/en/articles/requesting-organization-approval-for-oauth-apps"
target="_blank"
rel="noopener noreferrer"
>
{t('cant_see_what_youre_looking_for_question')}
</a>
</span>
)}
<OLButton
variant="secondary"
onClick={handleHide}
disabled={isImporting}
>
{t('cancel')}
</OLButton>
</OLModalFooter>
</>
)
}
export default function ImportFromGitHubModal({ onHide }: { onHide: () => void }) {
return (
<OLModal
id="git-import-modal"
show
animation
size="lg"
onHide={onHide}
backdrop="static"
>
<ImportFromGitHubModalContent handleHide={onHide} />
</OLModal>
)
}
@@ -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 (
<>
<OLModalBody>
<OLNotification
type="warning"
content={(
<Trans
i18nKey="ask_proj_owner_to_export_to_github"
values={{
projectOwnerEmail: projectSyncState.ownerEmail ?? '?',
}}
components={[
projectSyncState.ownerEmail ? <a href={`mailto:${projectSyncState.ownerEmail}`} /> : <></>
]}
/>
)}
/>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncCannotExportModal
@@ -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<void>()
const handleUnlink = () => {
runAsync(deleteJSON(`/project/${projectId}/github-sync`))
.then(() => setModalStatus('need-export'))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}
return (
<>
<OLModalBody>
<p>
{t('project_linked_to')}:&nbsp;
<a
href={`https://github.com/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
>
{projectSyncState.repoFullName}
</a>
</p>
<hr />
{!error ? (
<OLNotification
type="warning"
content={t('unlink_the_project_from_the_current_github_repo')}
/>
) : (
<OLNotification
type="error"
content={
error.info?.statusCode === 403 ? (
<Trans
i18nKey="ask_proj_owner_to_unlink_from_current_github"
values={{ projectOwnerEmail: error?.data?.ownerEmail ?? '?' }}
components={[
error?.data?.ownerEmail ? <a href={`mailto:${error.data.ownerEmail}`} /> : <></>
]}
/>
) : (
t('something_went_wrong_server')
)
}
/>
)}
</OLModalBody>
<OLModalFooter>
<div className="d-flex gap-2">
<OLButton
variant="danger"
onClick={handleUnlink}
disabled={isLoading || !!error}
>
{t('confirm')}
</OLButton>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
</div>
</OLModalFooter>
</>
)
}
export default GitSyncUnlinkModal
@@ -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 (
<>
<OLModalBody>
<OLNotification
type="warning"
content={t('github_merge_failed_error', { appName })}
/>
<p className="mt-2">
<Trans
i18nKey="github_manual_merge_user_prompt"
values={{sharelatex_branch: projectSyncState.unmergedBranchName }}
components={[<b />]}
/>
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
<OLButton
variant="primary"
onClick={
() => setModalStatus('run-merge-resolved')
}
>
{t('continue_github_merge')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncConflictModal
@@ -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<OrgsResponse>()
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<void>()
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 (
<>
<OLModalBody>
<h4>{t('export_project_to_github')}</h4>
<p>{t('project_not_linked_to_github')}</p>
{error && (
<OLNotification
type="error"
content={error}
/>
)}
{errorUserAndOrgs && (
<OLNotification
type="error"
content={t('something_went_wrong_server')}
/>
)}
<OLForm onSubmit={createRepo}>
<OLRow>
<OLCol xs={4}>
<OLFormGroup>
<OLFormLabel htmlFor="github-sync-owner">
{selectedOwner === userAndOrgs?.user
? t('owner')
: t('organization')}
</OLFormLabel>
<OLFormSelect
id="github-sync-owner"
name="org"
value={selectedOwner}
onChange={e => setSelectedOwner(e.target.value)}
>
<option key={userAndOrgs?.user} value={userAndOrgs?.user}>
{userAndOrgs?.user}
</option>
{userAndOrgs?.orgs.map(org => (
<option key={org} value={org}>
{org}
</option>
))}
</OLFormSelect>
</OLFormGroup>
</OLCol>
<OLCol xs={5}>
<OLFormGroup>
<OLFormLabel htmlFor="github-sync-name">
{t('repository_name')}
</OLFormLabel>
<OLFormControl
id="github-sync-name"
name="name"
type="text"
value={repoName}
onChange={e => setRepoName(e.target.value)}
/>
</OLFormGroup>
</OLCol>
</OLRow>
<OLRow>
<OLCol xs={12}>
<OLFormGroup>
<OLFormLabel htmlFor="github-sync-description">
{t('description')} ({t('optional')})
</OLFormLabel>
<OLFormControl
id="github-sync-description"
name="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
/>
</OLFormGroup>
</OLCol>
</OLRow>
<hr />
<fieldset>
<legend className="visually-hidden">
{t('repository_visibility')}
</legend>
<OLFormGroup>
<OLRow>
<OLCol xs={12}>
<OLFormCheckbox
type="radio"
id="public"
name="repository"
value="public"
checked={visibility === 'public'}
onChange={() => setVisibility('public')}
label={t('public', { defaultValue: 'Public' })}
description={t('github_public_description')}
/>
</OLCol>
</OLRow>
<OLRow>
<OLCol xs={12}>
<OLFormCheckbox
className="mr-1"
type="radio"
id="private"
name="repository"
value="private"
checked={visibility === 'private'}
onChange={() => setVisibility('private')}
label={t('private', { defaultValue: 'Private' })}
description={t('github_private_description')}
/>
</OLCol>
</OLRow>
</OLFormGroup>
</fieldset>
</OLForm>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
onClick={createRepo}
disabled={!repoName.trim() || isLoading}
isLoading={isLoading}
>
{t('create_project_in_github')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncExportModal
@@ -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 (
<>
<OLModalBody>
<div role="status" className="loading align-items-start">
<div aria-hidden="true" data-testid="ol-spinner" className="spinner-border spinner-border-sm"></div>
{t('checking_project_github_status')}
</div>
</OLModalBody>
{errorMessage && (
<OLNotification
type="error"
content={errorMessage}
/>
)}
<OLModalFooter>
{errorMessage && (
<OLButton
variant="danger-ghost"
onClick={() => setModalStatus('confirm-unlink')}
>
{t('unlink')}
</OLButton>
)}
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncLoadingModal
@@ -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<ProjectSyncState>()
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 (
<>
<OLModalBody>
<div
role="status"
className="loading align-items-start"
>
<div
aria-hidden="true"
data-testid="ol-spinner"
className="spinner-border spinner-border-sm"
></div>
{t('importing_and_merging_changes_in_github')}
</div>
</OLModalBody>
{error && (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncMergeModal
@@ -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<UnmergedCommitsResponse | null>()
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 (
<>
<OLModalBody>
<p>
{t('project_linked_to')}:&nbsp;
<a
href={`https://github.com/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
>
{projectSyncState.repoFullName}
</a>
</p>
{isLoading && (
<>
<div role="status" className="loading align-items-start">
<div
aria-hidden="true"
data-testid="ol-spinner"
className="spinner-border spinner-border-sm"
></div>
{t('loading')}
</div>
</>
)}
{isSuccess && (
<>
<hr />
{data.diverged && (
<OLNotification
type="warning"
content={t('github_repository_diverged')}
/>
)}
<h3 className="github-sync-commits-heading">
{t('recent_commits_in_github')}
<OLIconButton
icon="refresh"
size="sm"
variant="ghost"
onClick={loadUnmergedCommits}
accessibilityLabel={t('refresh')}
/>
</h3>
{data.commits?.length === 0 && (
<div>
<p className="small">{t('no_new_commits_in_github')}</p>
</div>
)}
{data.commits?.length > 0 && (
<div style={{ maxHeight: '200px', overflow: 'auto' }}>
{data.commits.map((commit: GitCommit) => (
<div key={commit.sha}>
<span className="small float-end">
<a
href={`https://github.com/${projectSyncState.repoFullName}/commit/${commit.sha}`}
target="_blank"
rel="noreferrer noopener"
>
{commit.sha.substring(0, 7)}
</a>
</span>
<a
href={`https://github.com/${projectSyncState.repoFullName}/commit/${commit.sha}`}
target="_blank"
className="commit-message"
rel="noreferrer noopener"
>
{commit.message}
</a>
<div className="small">
by {commit.author.name} &lt;{commit.author.email}&gt;
</div>
</div>
))}
</div>
)}
{data.isProjectUpdated && (
<>
<hr />
<OLForm>
<p>{t('sync_project_to_github_explanation', { appName })}</p>
<OLRow>
<OLCol xs={12}>
<OLFormGroup>
<OLFormControl
as="textarea"
rows={1}
value={commitMessage}
placeholder={t('github_commit_message_placeholder', { appName })}
onChange={(e) => setCommitMessage(e.target.value)}
/>
</OLFormGroup>
</OLCol>
</OLRow>
</OLForm>
</>
)}
</>
)}
{error && (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</OLModalBody>
<OLModalFooter>
<div className="d-flex justify-content-between w-100">
<div className="d-flex gap-2">
<OLButton
variant="danger-ghost"
onClick={() => setModalStatus('confirm-unlink')}
disabled={!isSuccess && error?.info?.statusCode !== 404}
>
{t('unlink')}
</OLButton>
</div>
<div className="d-flex gap-2">
<OLButton
variant="primary"
leadingIcon="sync"
onClick={() => setModalStatus('run-merge')}
disabled={!isSuccess}
>
{t('sync')}
</OLButton>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</div>
</div>
</OLModalFooter>
</>
)
}
export default GitSyncMergeOverviewModal
@@ -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<boolean>()
const {
runAsync,
data: projectSyncState,
setData: setProjectSyncState,
error,
setError,
} = useAsync<ProjectSyncState>()
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 (
<OLModal show={show} onHide={handleHide} backdrop="static">
<OLModalHeader closeButton>
<OLModalTitle>{t('sync_with_github')}</OLModalTitle>
</OLModalHeader>
{modalStatus === 'loading' && (
<GitSyncLoadingModal
handleHide={handleHide}
setModalStatus={setModalStatus}
errorMessage={errorMessage}
/>
)}
{modalStatus === 'need-export' && (
<GitSyncExportModal
projectId={projectId}
projectName={projectName}
handleHide={handleHide}
setModalStatus={setModalStatus}
/>
)}
{modalStatus === 'cannot-export' && (
<GitSyncCannotExportModal
projectSyncState={projectSyncState}
handleHide={handleHide}
/>
)}
{modalStatus === 'need-auth' && (
<GitSyncNeedAuthModal
handleHide={handleHide}
/>
)}
{modalStatus === 'need-permission' && (
<GitSyncNeedPermissionModal
handleHide={handleHide}
projectSyncState={projectSyncState}
/>
)}
{modalStatus === 'merge-overview' && (
<GitSyncMergeOverviewModal
projectId={projectId}
projectSyncState={projectSyncState}
setModalStatus={setModalStatus}
handleHide={handleHide}
commitMessage={commitMessage}
setCommitMessage={setCommitMessage}
/>
)}
{modalStatus === 'confirm-unlink' && (
<GitSyncConfirmUnlinkModal
projectId={projectId}
projectSyncState={projectSyncState}
setModalStatus={setModalStatus}
handleHide={handleHide}
/>
)}
{modalStatus === 'unlink-unavailable' && (
<GitSyncUnlinkUnavailableModal
projectId={projectId}
setModalStatus={setModalStatus}
handleHide={handleHide}
/>
)}
{(modalStatus === 'run-merge' ||
modalStatus === 'run-merge-resolved') && (
<GitSyncMergeModal
handleHide={handleHide}
modalStatus={modalStatus}
setModalStatus={setModalStatus}
commitMessage={commitMessage}
setCommitMessage={setCommitMessage}
projectId={projectId}
setProjectSyncState={setProjectSyncState}
/>
)}
{modalStatus === 'show-conflict' && (
<GitSyncConflictModal
projectSyncState={projectSyncState}
handleHide={handleHide}
setModalStatus={setModalStatus}
/>
)}
</OLModal>
)
}
export default GitSyncModal
@@ -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 (
<>
<OLModalBody>
<p>{t('link_to_github_description', { appName })}</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
<OLButton
variant="primary"
onClick={() => {
handleHide()
window.open(
'/user/github-sync/oauth2',
'githubAuth',
'width=600,height=700'
)
}}
>
{t('link_to_github')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncNeedAuthModal
@@ -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 (
<>
<OLModalBody>
<OLNotification
type="warning"
content={(
<Trans
i18nKey="ask_proj_owner_to_add_you_as_github_collaborator"
values={{
repoFullName: projectSyncState.repoFullName ?? '?',
projectOwnerEmail: projectSyncState.ownerEmail ?? '?',
}}
components={[
projectSyncState.repoFullName ? (
<a
href={`https://github.com/${projectSyncState.repoFullName}`}
target="_blank"
rel="noreferrer noopener"
/>
) : (
<></>
),
projectSyncState.ownerEmail ? <a href={`mailto:${projectSyncState.ownerEmail}`} /> : <></>
]}
/>
)}
/>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</>
)
}
export default GitSyncNeedPermissionModal
@@ -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<void>()
const handleUnlink = () => {
runAsync(deleteJSON(`/project/${projectId}/github-sync`))
.then(() => setModalStatus('need-export'))
.catch(err => debugConsole.error(err?.data?.message || err?.message || err))
}
return (
<>
<OLModalBody>
<OLNotification
type="error"
content={t('github_sync_repository_not_found_description')}
/>
{error && (
<OLNotification
type="error"
content={
error.info?.statusCode === 403 ? (
<Trans
i18nKey="ask_proj_owner_to_unlink_from_current_github"
values={{ projectOwnerEmail: error?.data?.ownerEmail ?? '?' }}
components={[
error?.data?.ownerEmail ? <a href={`mailto:${error.data.ownerEmail}`} /> : <></>
]}
/>
) : (
t('something_went_wrong_server')
)
}
/>
)}
</OLModalBody>
<OLModalFooter>
<div className="d-flex gap-2">
<OLButton
variant="primary"
onClick={handleUnlink}
disabled={isLoading}
>
{t('unlink_github_repository')}
</OLButton>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('cancel')}
</OLButton>
</div>
</OLModalFooter>
</>
)
}
export default GitSyncUnlinkUnavailableModal
@@ -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
}
@@ -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);
}
@@ -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