diff --git a/develop/docker-compose.yml b/develop/docker-compose.yml index 9e0fec661f..91e65aec70 100644 --- a/develop/docker-compose.yml +++ b/develop/docker-compose.yml @@ -187,3 +187,18 @@ services: - "127.0.0.1:80:3808" volumes: - ./webpack.config.dev-env.js:/overleaf/services/web/webpack.config.dev-env.js + + git-bridge: + image: quay.io/sharelatex/git-bridge:latest + expose: + - "8000" + ports: + - "8000:8000" + environment: + GIT_BRIDGE_API_BASE_URL: "http://web:3000/api/v0" + GIT_BRIDGE_OAUTH2_SERVER: "http://web:3000" + GIT_BRIDGE_POSTBACK_BASE_URL: "http://git-bridge:8000" + GIT_BRIDGE_ROOT_DIR: "/data/git-bridge" + LOG_LEVEL: "DEBUG" + volumes: + - ./data/git-bridge:/data/git-bridge diff --git a/develop/webpack.config.dev-env.js b/develop/webpack.config.dev-env.js index 49f85408a8..5878e0733a 100644 --- a/develop/webpack.config.dev-env.js +++ b/develop/webpack.config.dev-env.js @@ -14,6 +14,11 @@ module.exports = merge(base, { target: 'http://real-time:3026', ws: true, }, + { + context: '/git/**', + target: 'http://git-bridge:8000', + pathRewrite: { '^/git': '' } + }, { context: ['!**/*.js', '!**/*.css', '!**/*.json'], target: 'http://web:3000', diff --git a/server-ce/nginx/overleaf.conf b/server-ce/nginx/overleaf.conf index 77e59df5a0..a12088ae59 100644 --- a/server-ce/nginx/overleaf.conf +++ b/server-ce/nginx/overleaf.conf @@ -21,6 +21,22 @@ server { proxy_send_timeout 10m; } + location ^~ /git/ { + resolver_timeout 2s; + resolver 127.0.0.11 valid=10s; + set $git_upstream http://git-bridge:8000; + rewrite ^/git/(.*)$ /$1 break; + proxy_pass $git_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 10m; + proxy_send_timeout 10m; + } + location /socket.io { proxy_pass http://127.0.0.1:3026; proxy_http_version 1.1; diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 345d298534..017fc5765a 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1019,7 +1019,12 @@ module.exports = { referenceLinkingWidgets: [], importProjectFromGithubModalWrapper: [], importProjectFromGithubMenu: [], - editorLeftMenuSync: [], + editorLeftMenuSync: [ + Path.resolve( + __dirname, + '../modules/git-bridge/frontend/js/card/components/git-modal.tsx' + ), + ], editorLeftMenuManageTemplate: [ Path.resolve( __dirname, @@ -1032,7 +1037,12 @@ module.exports = { '../modules/template-gallery/frontend/js/features/template/components/menubar-manage-template' ), ], - oauth2Server: [], + oauth2Server: [ + Path.resolve( + __dirname, + '../modules/git-bridge/frontend/js/widget/components/git-integration-widget.tsx' + ) + ], managedGroupSubscriptionEnrollmentNotification: [], managedGroupEnrollmentInvite: [], ssoCertificateInfo: [], @@ -1069,7 +1079,12 @@ module.exports = { '../modules/full-project-search/frontend/js/components/full-project-search.tsx' ), ], - integrationPanelComponents: [], + integrationPanelComponents: [ + Path.resolve( + __dirname, + '../modules/git-bridge/frontend/js/card/components/git-integration-card.tsx' + ), + ], referenceSearchSetting: [], errorLogsComponents: [], referenceIndices: [], @@ -1089,6 +1104,7 @@ module.exports = { 'authentication/oidc', 'admin-tools', // import after authentication 'template-gallery', + 'git-bridge', ], viewIncludes: {}, @@ -1102,7 +1118,6 @@ module.exports = { 'app/views/project/ide-react': [`img-src 'self' data: blob:`], }, }, - unsupportedBrowsers: { ie: '<=11', safari: '<=14', diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 332dca49e2..bfca26391c 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -512,8 +512,11 @@ "git_authentication_token_create_modal_info_2": "<0>Du bekommst diesen Anmelde-Token nur einmal angezeigt, bitte kopiere ihn und bewahre ihn sicher auf. Für weitere Anweisungen zur Verwendung von Anmelde-Tokens, besuche unsere <1>Hilfe-Seite.", "git_bridge_modal_click_generate": "Klicke jetzt auf Token generieren um deinen ersten Anmeldungs-Token zu erstellen. Oder erstelle ihn später in deinen Kontoeinstellungen.", "git_bridge_modal_enter_authentication_token": "Wenn Du nach einem Passwort gefragt wirst, gib deinen neuen Anmeldungs-Token ein:", + "git_bridge_modal_git_clone_your_project": "Klonen Dein Projekt über den untenstehenden Link und einen Git-Authentifizierungstoken", "git_bridge_modal_see_once": "Du siehst diesen Token nur einmal. Um ihn zu löschen oder einen weiteren zu generieren, besuche die Kontoeinstellungen. Für detaillierte Anweisungen und Problembehebung, besuche unsere <0>Hilfe-Seite.", "git_bridge_modal_use_previous_token": "Wenn Du nach einem Passwort gefragt wirst, kannst Du einen zuvor generierten Git-Anmeldungs-Token verwenden. Oder Du kannst einen Neuen in den Kontoeinstellungen generieren. Für mehr Hilfe, besuche unsere <0>Hilfe-Seite.", + "git_clone_project_command": "Git-Clone-Projektbefehl", + "git_clone_this_project": "Dieses Projekt mit Git klonen.", "git_integration": "Git-Integration", "git_integration_info": "Mit der Git-Integration kannst Du Overleaf-Projekte Git-clonen. Für weitere Anweisungen hierfür, besuche <0>unsere Hilfe-Seite.", "github_commit_message_placeholder": "Commit-Meldung für Änderungen die in __appName__ gemacht wurden", @@ -1303,7 +1306,9 @@ "to_many_login_requests_2_mins": "In dieses Konto wurde sich zu häufig eingeloggt. Bitte warte 2 Minuten, bevor du es noch einmal versuchst.", "to_modify_your_subscription_go_to": "Um dein Abo zu ändern, gehe zu", "toggle_compile_options_menu": "Menü der Kompilieroptionen umschalten", + "token": "Token", "token_access_failure": "Zugriff kann nicht gewährt werden", + "token_limit_reached": "Du hast das Limit von 10 Tokens erreicht. Um einen neuen Authentifizierungstoken zu generieren, lösche bitte einen bestehenden.", "too_many_attempts": "Zu viele Versuche. Bitte warte eine Weile und versuche es erneut.", "too_many_files_uploaded_throttled_short_period": "Zu viele Dateien hochgeladen, deine Uploads wurden für kurze Zeit gedrosselt.", "too_many_requests": "Es gingen in kurzer Zeit zu viele Anfragen ein. Bitte warte einen Moment und versuche es erneut.", @@ -1443,6 +1448,13 @@ "you_will_be_able_to_contact_us_any_time_to_share_your_feedback": "Du kannst uns jederzeit kontaktieren, um uns dein Feedback mitzuteilen", "your_affiliation_is_confirmed": "Deine Zugehörigkeit zu <0>__institutionName__ ist bestätigt.", "your_browser_does_not_support_this_feature": "Entschuldigung, dein Browser unterstützt diese Funktion nicht. Bitte aktualisiere deinen Browser auf die neueste Version.", + "your_git_access_info": "Deine Git-Authentifizierungstoken solltest Du eingeben, wenn Du nach einem Passwort gefragt wirst.", + "your_git_access_info_bullet_1": "Du kannst bis zu 10 Tokens haben.", + "your_git_access_info_bullet_2": "Wenn Du das Maximum erreicht hast, musst Du einen Token löschen, bevor Du einen neuen erstellen kannst.", + "your_git_access_info_bullet_3": "Du kannst einen Token mit dem <0>Token generieren-Button erstellen.", + "your_git_access_info_bullet_4": "Den vollständigen Token kannst Du nach der ersten Erstellung nicht mehr sehen. Bitte kopiere ihn und bewahre ihn sicher auf.", + "your_git_access_info_bullet_5": "Bereits erstellte Tokens werden hier angezeigt.", + "your_git_access_tokens": "Deine Git-Authentifizierungstoken", "your_new_plan": "Dein neues Abonnement", "your_plan": "Dein Abo", "your_plan_is_changing_at_term_end": "Dein Abonnement ändert sich am Ende des aktuellen Abrechnungszeitraums in <0>__pendingPlanName__.", diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index 7d46724a5f..a931571e14 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -55,6 +55,7 @@ "activation_link": "Ссылка для активации", "activation_token_expired": "Срок действия Вашего ключа истёк. Вам необходимо запросить новый ключ активации.", "add": "Добавить", + "add_another_token": "Добавить ещё один токен", "add_your_first_group_member_now": "Добавьте первых участников группы сейчас", "added": "добавлены", "adding": "Добавление", @@ -103,6 +104,7 @@ "clear_sessions_success": "Сессии завершены", "clearing": "Очистка", "click_here_to_view_sl_in_lng": "Кликните здесь, для использования __appName__ на <0>__lngName__", + "clone_with_git": "Клонировать при помощи git", "close": "Закрыть", "clsi_maintenance": "На сервере компиляции проводятся ремонтные работы, и он будет вскоре доступен снова.", "cn": "Китайский (упрощённый)", @@ -134,6 +136,7 @@ "create_account": "Создать аккаунт", "create_new_subscription": "Создать новую подписку", "create_project_in_github": "Создать проект на GitHub", + "created_at": "Создано", "creating": "Создание", "credit_card": "банковская карта", "cs": "Чешский", @@ -174,6 +177,7 @@ "es": "Испанский", "every": "каждый", "example_project": "Использовать пример", + "expires": "Истекает", "expiry": "Срок действия", "export_project_to_github": "Экспорт проекта на GitHub", "failed_to_publish_as_a_template": "Не удалось создать шаблон.", @@ -193,8 +197,14 @@ "free_dropbox_and_history": "Бесплатные Dropbox и История", "full_doc_history": "Полная история изменений", "general": "Общие", + "generate_token": "Создать токен", "generic_something_went_wrong": "Извините, что-то пошло не так", "get_in_touch": "Связаться с нами", + "git": "Git", + "git_bridge_modal_git_clone_your_project": "Клонируйте Ваш проект при помощи ссылки и токена аутентификации git", + "git_clone_project_command": "Команда git clone для проекта", + "git_clone_this_project": "Клонируйте проект при помощи git.", + "git_integration": "Интеграция с Git", "github_commit_message_placeholder": "Сообщение о фиксации изменений в __appName__...", "github_is_premium": "Синхронизация с GitHub доступна только в премиум аккаунте", "github_merge_failed": "Ваши изменения в __appName__ и GitHub не были автоматически объединены. Пожалуйста, вручную объедините ветку <0>__sharelatex_branch__ с веткой по умолчанию в git. Нажмите ниже, чтобы продолжить после ручного объединения.", @@ -242,6 +252,7 @@ "language": "Язык", "last_modified": "Последнее изменение", "last_name": "Фамилия", + "last_used": "Последнее использование", "latex_templates": "Шаблоны", "learn_more": "Узнать больше", "leave_group": "Покинуть группу", @@ -453,7 +464,8 @@ "showing_x_out_of_n_users": "Показано __x__ из __n__ пользователей", "signed_up": "Зарегистрирован", "site_description": "Простой в использовании онлайн редактор LaTeX. Не требует установки, поддерживает совместную работу в реальном времени, контроль версий, сотни шаблонов LaTeX и многое другое.", - "somthing_went_wrong_compiling": "К сожалению, что-то пошло не так и мы не смогли скомпИлировать Ваш проект. Попробуйте еще раз через пару минут.", + "something_went_wrong_compiling": "К сожалению, что-то пошло не так и мы не смогли скомпИлировать Ваш проект. Попробуйте еще раз через пару минут.", + "something_went_wrong_server": "Извините, что-то пошло не так. Попробуйте еще раз.", "source": "Исходный код", "spell_check": "Проверка правописания", "start_free_trial": "Попробовать бесплатно!", @@ -501,6 +513,8 @@ "title": "Название", "to_many_login_requests_2_mins": "Было предпринято слишком много попыток входа. Пожалуйста, подождите 2 минуты, прежде чем пробовать снова", "to_modify_your_subscription_go_to": "Для изменения подписки перейдите по ссылке:", + "token": "Токен", + "token_limit_reached": "Вы достигли лимита в 10 токенов. Чтобы создать новый токен аутентификации, удалите один из имеющихся.", "too_many_files_uploaded_throttled_short_period": "Слишком много файлов загружено за раз - на короткое время загрузка была приостановлена.", "too_recently_compiled": "Этот проект был скомпилирован совсем недавно, поэтому компиляция была пропущена.", "total_words": "Количество слов", @@ -559,6 +573,13 @@ "you": "Вы", "you_cant_overwrite_it": "Перезаписать нельзя.", "you_have_added_x_of_group_size_y": "Вы добавили <0>__addedUsersSize__ из <1>__groupSize__ доступных участников", + "your_git_access_info": "При запросе пароля используйте ваш токен аутентификации Git.", + "your_git_access_info_bullet_1": "Вы можете иметь до 10 токенов.", + "your_git_access_info_bullet_2": "Если вы достигли максимума, необходимо удалить имеющийся токен, прежде чем создавать новый.", + "your_git_access_info_bullet_3": "Вы можете создать токен с помощью кнопки <0>Создать токен.", + "your_git_access_info_bullet_4": "После того как вы создадите токен, у вас больше не будет возможности увидеть его снова. Пожалуйста, скопируйте его и сохраните в безопасности.", + "your_git_access_info_bullet_5": "Ранее созданные токены будут отображаться здесь.", + "your_git_access_tokens": "Ваши токены аутентификации Git", "your_plan": "Ваш тариф", "your_projects": "Созданные мной", "your_sessions": "Ваши сессии", diff --git a/services/web/modules/git-bridge/app/src/GitBridgeAuthMiddleware.mjs b/services/web/modules/git-bridge/app/src/GitBridgeAuthMiddleware.mjs new file mode 100644 index 0000000000..df609c9c87 --- /dev/null +++ b/services/web/modules/git-bridge/app/src/GitBridgeAuthMiddleware.mjs @@ -0,0 +1,41 @@ +import logger from '@overleaf/logger' +import AuthorizationManager from '../../../../app/src/Features/Authorization/AuthorizationManager.mjs' +import GitBridgePATManager from './GitBridgePATManager.mjs' + +const permissionChecks = { + read: AuthorizationManager.promises.canUserReadProject, + write: AuthorizationManager.promises.canUserWriteProjectContent +} + +export default function ensureTokenProjectAccess(permission) { + const checkPermission = permissionChecks[permission] + if (!checkPermission) { + throw new Error(`Invalid permission: ${permission}`) + } + + return async function (req, res, next) { + try { + const projectId = req.params.project_id + if (!projectId) return res.sendStatus(400) + + const header = req.headers.authorization || '' + const [scheme, token] = header.trim().split(/\s+/, 2) + if (scheme?.toLowerCase() !== 'bearer' || !token) { + return res.sendStatus(401) + } + + const userId = await GitBridgePATManager.getUserId(token) + if (!userId) return res.sendStatus(401) + + const allowed = await checkPermission(userId, projectId, null) + + if (!allowed) return res.sendStatus(403) + + req.user_id = userId + return next() + } catch (err) { + logger.error({ err }, 'Failed to check personal access token') + return res.sendStatus(500) + } + } +} diff --git a/services/web/modules/git-bridge/app/src/GitBridgeController.mjs b/services/web/modules/git-bridge/app/src/GitBridgeController.mjs new file mode 100644 index 0000000000..f07d335e36 --- /dev/null +++ b/services/web/modules/git-bridge/app/src/GitBridgeController.mjs @@ -0,0 +1,261 @@ +// Controller for API v0 endpoints used by git-bridge +// These endpoints allow git-bridge to access project metadata, versions, and snapshots. + +import settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import { expressify } from '@overleaf/promise-utils' +import { fetchJson } from '@overleaf/fetch-utils' + +import JsonWebToken from '../../../../app/src/infrastructure/JsonWebToken.mjs' +import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs' +import HistoryController from '../../../../app/src/Features/History/HistoryController.mjs' +import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' +import GitBridgeHandler from './GitBridgeHandler.mjs' + +const PROJECT_HISTORY = settings.apis.project_history.url +const V1_HISTORY = settings.apis.v1_history.url + +/* + Entrypoint (GET): /api/v0/docs/:project_id + Called by git-bridge to retrieve metadata about the latest project version. + + The response must contain: + { + latestVerId: number, + latestVerAt: string, + latestVerBy: { email: string, name: string } + } + + This data is retrieved from the project-history service: + /project/${projectId}/version +*/ + +async function getDoc(req, res, next) { + + const projectId = req.params.project_id + + try { + const project = await ProjectGetter.promises.getProject(projectId, { _id: 1 }) + if (!project) throw new Error('Project not found') + + const latestVerInfo = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/version`) + const timestamp = latestVerInfo.timestamp || new Date().toISOString() + const userId = latestVerInfo.v2Authors && latestVerInfo.v2Authors[0] + + let user = null + if (userId) { + user = await UserGetter.promises.getUser(userId, { first_name: 1, last_name: 1, email: 1 }) + } + + const name = HistoryController._displayNameForUser(user) + const email = user?.email || 'anonymous@nowhere' + + res.json({ + latestVerId: latestVerInfo.version, + latestVerAt: timestamp, + latestVerBy: { email, name }, + }) + } catch (err) { + logger.error({ err, projectId }, 'Error retrieving the latest version metadata') + return res.sendStatus(400) + } +} + +/* + Entrypoint (GET): /api/v0/docs/:project_id/saved_vers + Called by git-bridge to retrieve metadata about labeled (saved) versions. + + The response must be an array of objects: + { + versionId: number, + comment: string, + createdAt: timestamp, + user: { email: string, name: string } + } + + This data is retrieved from the project-history service: + /project/${projectId}/labels +*/ + +async function getSavedVers(req, res, next) { + + const projectId = req.params.project_id + + try { + const project = await ProjectGetter.promises.getProject(projectId, { _id: 1 }) + if (!project) throw new Error('Project is not found') + + let labels = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/labels`) + + const userIdSet = new Set(labels.map(label => label.user_id)) + // For backward compatibility: labels created anonymously may not contain user_id + userIdSet.delete(undefined) + const users = await UserGetter.promises.getUsers(Array.from(userIdSet), { first_name: 1, last_name: 1, email: 1 }) + + const savedVers = [] + labels.forEach(label => { + const user = users.find(u => String(u._id) === label.user_id) + const name = HistoryController._displayNameForUser(user) + const email = user?.email || 'anonymous@nowhere' + savedVers.push({ + versionId: label.version, + comment: label.comment, + createdAt: label.created_at, + user: { name, email }, + }) + }) + + res.json(savedVers) + + } catch (err) { + logger.error({ err, projectId }, 'Error retrieving the saved versions metadata') + return res.sendStatus(400) + } +} + +/* + Entrypoint (GET): /api/v0/docs/:project_id/snapshots/:version + Called by git-bridge to retrieve a full project snapshot for a specific version. + + Needs in the response the following object: + { + srcs: [ [ fileContent: string, pathname: string ], ... ], + atts: [ [ downloadUrl: string, pathname: string ], ... ], + } + + This data is retrieved from the project-history service: + entrypoint: /project/${projectId}/version/${version} +*/ + +async function getSnapshot(req, res, next) { + + const projectId = req.params.project_id + const versionString = req.params.version + + try { + if (!versionString) throw new Error('No version specified') + const version = Number(versionString) + + const project = await ProjectGetter.promises.getProject(projectId, { _id: 1 }) + if (!project) throw new Error('Project not found') + + const snapshot = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/version/${version}`) + const files = snapshot.files || {} + + const srcs = [] + const atts = [] + + for (const [pathname, file] of Object.entries(files)) { + if (!file?.data) continue + + if (!GitBridgeHandler.normalizeAndValidateFilePath(pathname)) { + throw new Error(`Invalid pathname: ${pathname}`) + } + + const { content, hash } = file.data + + if (content !== undefined) { + // Updated text file: return file content directly + srcs.push([content, pathname]) + + } else if (hash) { + // Binary file ("file") or unchanged text file (thoght it's "doc", add to atts anyway) + // Git-bridge downloads it directly from v1-history using a temporary JWT. + const token = await JsonWebToken.promises.sign( + { project_id: projectId }, + { expiresIn: '10m' } + ) + const downloadUrl = `${V1_HISTORY}/projects/${projectId}/blobs/${hash}?token=${token}` + + atts.push([downloadUrl, pathname]) + + } else { + throw new Error('Snapshot: both content and hash missing: ', pathname) + } + } + + res.json({ srcs, atts }) + + } catch (err) { + + logger.error({ err, projectId, versionString }, 'Error in getSnapshot') + return res.sendStatus(400) + } +} + +/* + Entry point (POST): /api/v0/docs/:project_id/snapshots + Called by git-bridge after a push. + + body: + { + latestVerId: number, + files: [ + { name: string, url: string | null } + ], + postbackUrl: string + } + + latestVerId: + Version that is expected to be updated + files: + Array describing the complete file set of the new project version + files.name: + File path in the project + files.url: + If not null, the file is new or modified and must be downloaded from + git-bridge using this URL, if null, the file is unchanged + postbackUrl: + URL where the update result must be posted + + returns: + { code: 'accepted' } or { code: 'outOfDate' } + Any other response is treated by git-bridge as an error. +*/ + +async function postSnapshot(req, res) { + + const projectId = req.params.project_id + const { latestVerId, files, postbackUrl } = req.body + + if (!postbackUrl) { + logger.error({ projectId }, 'postSnapshot: empty postbackUrl') + return res.sendStatus(400) + } + + const userId = req.user_id || null // from auth middleware + + try { + const project = await ProjectGetter.promises.getProject(projectId, { _id: 1 }) + if (!project) { + logger.error({ projectId }, 'Project not found') + return res.sendStatus(404) + } + + const { version: olLatestVerId } = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/version`) + + if (latestVerId !== olLatestVerId) { + // version in Overleaf is changed since last pull, git user needs to pull again + return res.status(409).json({ code: 'outOfDate' }) + } + + // When "accepted" is returned, git-bridge does not finish the push yet. + // Instead it waits for the postback callback (performed in pushUpdate) + // So it should be returned when the server has accepted the request but has not finished processing yet. + res.status(200).json({ code: 'accepted' }) + + GitBridgeHandler.pushUpdate(projectId, files, postbackUrl, userId) + .catch(err => logger.error({ err, projectId }, 'Error pushing update')) + + } catch (err) { + logger.error({ err, projectId }, 'Error posting snapshot') + res.sendStatus(500) + } +} + +export default { + getDoc: expressify(getDoc), + getSavedVers: expressify(getSavedVers), + getSnapshot: expressify(getSnapshot), + postSnapshot: expressify(postSnapshot), +} diff --git a/services/web/modules/git-bridge/app/src/GitBridgeHandler.mjs b/services/web/modules/git-bridge/app/src/GitBridgeHandler.mjs new file mode 100644 index 0000000000..890599d779 --- /dev/null +++ b/services/web/modules/git-bridge/app/src/GitBridgeHandler.mjs @@ -0,0 +1,153 @@ +// Helper functions used by GitBridgeController. + +import path from 'path' +import settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import { fetchJson } from '@overleaf/fetch-utils' +import { fetchStream } from '@overleaf/fetch-utils' + +import ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs' +import UpdateMerger from '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs' + +const PROJECT_HISTORY = settings.apis.project_history.url + +function normalizeAndValidateFilePath(fileName) { + if (!fileName || fileName.includes('\0')) return null + + const normalized = path.posix.normalize(fileName) + + if (normalized.startsWith('/') || normalized.startsWith('..')) return null + if (normalized === '.git' || normalized.startsWith('.git/')) return null + + return normalized +} + +function validateFileName(fileName) { + const normalized = normalizeAndValidateFilePath(fileName) + +// if normalized path is not acceptable: error +// if normalized path differs from original: suggest rename +// otherwise the filename is valid + if (!normalized) { + return { file: fileName, state: 'error' } + } + if (normalized !== fileName) { + return { file: fileName, cleanFile: normalized } + } + return null +} + +// Push update to Overleaf. +// update is an array of objects: { name: string, url: string | null }, provided by git-bridge +// name: file path in the new project version (without leading '/') +// url: download link from git-bridge, null means the file has not changed +// Any existing entities whose paths are not present in update must be deleted. +// Note: entity paths returned by ProjectEntityHandler include a leading '/' + +async function pushUpdate(projectId, update, postbackUrl, userId) { + + const invalidFiles = [] + + update.forEach(file => { + const validationError = validateFileName(file.name) + if (validationError) invalidFiles.push(validationError) + }) + + if (invalidFiles.length > 0) { + await postback(postbackUrl, { code: 'invalidFiles', errors: invalidFiles }) + return + } + + try { + const { docs, files } = await ProjectEntityHandler.promises.getAllEntities(projectId) + + const entityPaths = [...docs.map(d => d.path), ...files.map(f => f.path)] + // prepend '/', paths in the entities have it + const pathsInUpdate = new Set(update.map(f => '/' + f.name)) + + // Entities present in the project but missing in the update must be deleted + const deletedPaths = entityPaths.filter(p => !pathsInUpdate.has(p)) + + for (const path of deletedPaths) { + await UpdateMerger.promises.deleteUpdate(userId, projectId, path, 'git-bridge') + } + + // Apply file updates received from git-bridge + for (const file of update) { + if (!file.url) continue + + const stream = await fetchStream(file.url) + const fileName = '/' + file.name + await UpdateMerger.promises.mergeUpdate(userId, projectId, fileName, stream, 'git-bridge') + } + + } catch (err) { + await postback(postbackUrl, { code: 'error' }) + throw err + } + + const { version } = await fetchJson(`${PROJECT_HISTORY}/project/${projectId}/version`) + await postback(postbackUrl, { code: 'upToDate', latestVerId: version }) +} + +/* +Postback expected data: + +Success: { code: "upToDate", latestVerId: number } + latestVerId is a new version ID created by the push + +Error: +{ + code: "invalidFiles", + errors: [ + { + file: "path/to/file", + cleanFile: "suggested_name" + }, + { + file: "path/to/file", + state: "disallowed" + } + ] +} + cleanFile is interpreted as a sanitized replacement filename + state "disallowed": git bridge gives a hint "wrong file extension" (not used) + +Error: { code: "error" } + Generic error. + +Error (not used): { code: "outOfDate" } + The pushed snapshot was based on an outdated version. + It can be used when the snapshot was accepted initially, + but later processing discovered the base version was outdated. + +Error (not used): +{ + code: "invalidProject", + errors: [ + "error message 1", + "error message 2" + ] +} + +*/ + +async function postback(postbackUrl, code) { + + try { + await fetchJson(postbackUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(code), + }) + + } catch (err) { + logger.error({ err, postbackUrl, code }, 'Failed to post back to git-bridge') + + } +} + +export default { + normalizeAndValidateFilePath, + pushUpdate, +} diff --git a/services/web/modules/git-bridge/app/src/GitBridgePATController.mjs b/services/web/modules/git-bridge/app/src/GitBridgePATController.mjs new file mode 100644 index 0000000000..3db26a4b1f --- /dev/null +++ b/services/web/modules/git-bridge/app/src/GitBridgePATController.mjs @@ -0,0 +1,98 @@ +import logger from '@overleaf/logger' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' +import EmailHandler from '../../../../app/src/Features/Email/EmailHandler.mjs' +import GitBridgePATManager from './GitBridgePATManager.mjs' + +const MAX_PAT_COUNT = 10 + +async function _sendSecurityAlertCreatedPAT(user) { + const emailOptions = { + to: user.email, + actionDescribed: `a new Git authentication token has been generated for your account ${user.email}`, + action: 'new Git authentication token generated', + } + try { + await EmailHandler.promises.sendEmail('securityAlert', emailOptions) + } catch (error) { + // log error when sending security alert email but do not pass back + logger.error( + { error, userId: user._id }, + 'could not send security alert new Git authentication token generated' + ) + } +} + +const GitBridgePATController = { + async getUserPersonalAccessTokens(req, res) { + const user = SessionManager.getSessionUser(req.session) + if (!user) return res.sendStatus(401) + + try { + const userId = user._id + const tokens = + await GitBridgePATManager.getTokens(userId) + return res.json(tokens) + } catch (err) { + logger.error({ err, userId }, 'Failed to get personal access tokens') + return res.sendStatus(500) + } + }, + + async createPersonalAccessToken(req, res){ + const user = SessionManager.getSessionUser(req.session) + if (!user) return res.sendStatus(401) + + try { + const count = await GitBridgePATManager.countTokens(user._id) + if (count >= MAX_PAT_COUNT) return res.sendStatus(403) + + const token = await GitBridgePATManager.createToken(user._id) + + // no need to wait, errors are logged and not passed back + _sendSecurityAlertCreatedPAT(user) + + return res.json(token) + + } catch (err) { + logger.error({ err }, 'Error in PAR create') + return res.sendStatus(500) + } + }, + + async deletePersonalAccessToken(req, res){ + const user = SessionManager.getSessionUser(req.session) + if (!user) return res.sendStatus(401) + const userId = user._id + + const tokenId = req.params?.token_id + if (!tokenId) return res.sendStatus(400) + + try { + const deleted = await GitBridgePATManager.deleteToken(tokenId, userId) + if (!deleted) return res.sendStatus(404) + + return res.sendStatus(200) + } catch (err) { + logger.error({ err, userId, tokenId }, 'Error in PAT delete') + return res.sendStatus(500) + } + }, + + async validatePersonalAccessToken(req, res) { + const header = req.headers.authorization || '' + const [scheme, token] = header.trim().split(/\s+/, 2) + if (scheme?.toLowerCase() !== 'bearer' || !token) return res.sendStatus(401) + + try { + const userId = await GitBridgePATManager.getUserId(token) + if (!userId) return res.sendStatus(401) + + return res.sendStatus(200) + } catch (err) { + logger.error({ err }, 'Error in PAT validate') + return res.sendStatus(500) + } + }, +} + +export default GitBridgePATController diff --git a/services/web/modules/git-bridge/app/src/GitBridgePATManager.mjs b/services/web/modules/git-bridge/app/src/GitBridgePATManager.mjs new file mode 100644 index 0000000000..e078d79aa5 --- /dev/null +++ b/services/web/modules/git-bridge/app/src/GitBridgePATManager.mjs @@ -0,0 +1,125 @@ +import logger from '@overleaf/logger' +import crypto from 'crypto' +import { ObjectId } from 'mongodb' +import { db } from '../../../../app/src/infrastructure/mongodb.mjs' +import { OauthApplication } from '../../../../app/src/models/OauthApplication.mjs' + +const PAT_PREFIX = 'olp_' +const PAT_LENGTH = 36 // without 'olp_' prefix +const PAT_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +function _hashToken(token) { + return crypto.createHash('sha256').update(token).digest('hex') +} + +function _generateToken(length) { + const patCharsLength = PAT_CHARS.length + let token = '' + for (let i = 0; i < length; i++) { + token += PAT_CHARS[crypto.randomInt(patCharsLength)] + } + return token +} + +const GitBridgePATManager = { + + async getTokens(user_id) { + const query = { + user_id, + scope: /\bgit_bridge\b/, + type: 'personal_access_token' + } + const projection = { + accessTokenPartial: 1, + createdAt: 1, + expiresAt: 1, + lastUsedAt: 1 + } + + return await db.oauthAccessTokens + .find(query, { projection }) + .sort({ createdAt: 1 }) + .toArray() + }, + + async countTokens(user_id) { + const query = { + user_id, + scope: /\bgit_bridge\b/, + type: 'personal_access_token' + } + return db.oauthAccessTokens.countDocuments(query) + }, + + async createToken(user_id) { + const token = PAT_PREFIX + _generateToken(PAT_LENGTH) + const createdAt = new Date() + const expiresAt = new Date(createdAt) + expiresAt.setFullYear(expiresAt.getFullYear() + 1) + const accessTokenPartial = token.substring(0, 8) + + const result = await db.oauthAccessTokens.insertOne({ + accessToken: _hashToken(token), + accessTokenPartial, + user_id, + type: 'personal_access_token', + scope: 'git_bridge', + createdAt, + expiresAt, + }) + + return { + _id: result.insertedId, + accessToken: token, + accessTokenPartial, + createdAt, + expiresAt + } + }, + + async deleteToken(tokenId, user_id) { + const query = { + _id: new ObjectId(tokenId), + user_id, + } + const result = await db.oauthAccessTokens.deleteOne(query) + return result?.deletedCount || 0 + }, + + async getUserId(token) { + if (!token?.startsWith(PAT_PREFIX)) return null + + const now = new Date() + const objToken = await db.oauthAccessTokens.findOne( + { + accessToken: _hashToken(token), + type: 'personal_access_token', + scope: /\bgit_bridge\b/, + expiresAt: { $gt: now } + }, { projection: { user_id: 1 } } + ) + + if (!objToken?.user_id) return null + +// does the user still exists and not deleted? +// tokens of a deleted user are not deleted until user expires + const user = await db.users.findOne( + { _id: new ObjectId(objToken.user_id) }, + { projection: { _id: 1 } } + ) + + if (!user) return null + + // non-blocking + db.oauthAccessTokens.updateOne( + { _id: objToken._id }, + { $set: { lastUsedAt: now } } + ).catch(err => + logger.error({ err }, 'Failed to update lastUsedAt') + ) + + return objToken.user_id + }, +} + +export default GitBridgePATManager diff --git a/services/web/modules/git-bridge/app/src/GitBridgeRouter.mjs b/services/web/modules/git-bridge/app/src/GitBridgeRouter.mjs new file mode 100644 index 0000000000..a7bf40d144 --- /dev/null +++ b/services/web/modules/git-bridge/app/src/GitBridgeRouter.mjs @@ -0,0 +1,56 @@ +import logger from '@overleaf/logger' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs' +import GitBridgeController from './GitBridgeController.mjs' +import ensureTokenProjectAccess from './GitBridgeAuthMiddleware.mjs' +import GitBridgePATController from './GitBridgePATController.mjs' +import RateLimiterMiddleware from '../../../../app/src/Features/Security/RateLimiterMiddleware.mjs' +import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.mjs' + +const oauthTokenInfoRateLimiter = new RateLimiter('oauth-token-info', { + points: 30, + duration: 60, +}) + +export default { + apply(webRouter, privateApiRouter) { + logger.debug({}, 'Init git-bridge router') + + privateApiRouter.get('/api/v0/docs/:project_id', + ensureTokenProjectAccess('read'), + GitBridgeController.getDoc + ) + privateApiRouter.get( + '/api/v0/docs/:project_id/saved_vers', + ensureTokenProjectAccess('read'), + GitBridgeController.getSavedVers + ) + privateApiRouter.get( + '/api/v0/docs/:project_id/snapshots/:version', + ensureTokenProjectAccess('read'), + GitBridgeController.getSnapshot + ) + privateApiRouter.post( + '/api/v0/docs/:project_id/snapshots', + ensureTokenProjectAccess('write'), + GitBridgeController.postSnapshot + ) +// Called by git-bridge to validate a PAT + webRouter.get('/oauth/token/info', + RateLimiterMiddleware.rateLimit(oauthTokenInfoRateLimiter), + GitBridgePATController.validatePersonalAccessToken + ) + webRouter.get('/git-bridge/personal-access-tokens', + AuthenticationController.requireLogin(), + GitBridgePATController.getUserPersonalAccessTokens + ) + webRouter.post('/git-bridge/personal-access-tokens', + AuthenticationController.requireLogin(), + GitBridgePATController.createPersonalAccessToken + ) + webRouter.delete('/git-bridge/personal-access-tokens/:token_id', + AuthenticationController.requireLogin(), + GitBridgePATController.deletePersonalAccessToken + ) + } +} diff --git a/services/web/modules/git-bridge/frontend/js/card/components/git-integration-card.tsx b/services/web/modules/git-bridge/frontend/js/card/components/git-integration-card.tsx new file mode 100644 index 0000000000..6084264222 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/card/components/git-integration-card.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { useProjectContext } from '@/shared/context/project-context' +import IntegrationCard from '@/features/ide-redesign/components/integrations-panel/integration-card' +import GitLogoOrange from '@/shared/svgs/git-logo-orange' + +import GitModalWrapper from './git-modal-wrapper' + +function GitSyncCard() { + const { t } = useTranslation() + const { project } = useProjectContext() + const [show, setShow] = useState(false) + + const projectId = project?._id + if (!projectId) return null + + return ( + <> + } + onClick={() => setShow(true)} + /> + + setShow(false)} + projectId={projectId} + /> + + ) +} + +export default GitSyncCard diff --git a/services/web/modules/git-bridge/frontend/js/card/components/git-modal-content.tsx b/services/web/modules/git-bridge/frontend/js/card/components/git-modal-content.tsx new file mode 100644 index 0000000000..5ad515bf16 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/card/components/git-modal-content.tsx @@ -0,0 +1,75 @@ +import { Trans, useTranslation } from 'react-i18next' +import { CopyToClipboard } from '@/shared/components/copy-to-clipboard' + +import { + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' + +type Props = { + handleHide: () => void + projectId: string +} + +export default function GitModalContent({ + handleHide, + projectId, +}: Props) { + const { t } = useTranslation() + + const gitCloneCommand = `git clone ${window.location.protocol}//git@${window.location.host}/git/${projectId}` + + return ( + <> + + {t('clone_with_git')} + + + +

{t('git_bridge_modal_git_clone_your_project')}

+ +
+ + + {gitCloneCommand} + + + +
+ , + ]} + /> +
+ + + + {t('close')} + + + {t('go_to_settings')} + + + + ) +} diff --git a/services/web/modules/git-bridge/frontend/js/card/components/git-modal-wrapper.tsx b/services/web/modules/git-bridge/frontend/js/card/components/git-modal-wrapper.tsx new file mode 100644 index 0000000000..468e03ade3 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/card/components/git-modal-wrapper.tsx @@ -0,0 +1,29 @@ +import { OLModal } from '@/shared/components/ol/ol-modal' +import GitModalContent from './git-modal-content' + +type Props = { + show: boolean + projectId: string + handleHide: () => void +} + +export default function GitModalWrapper({ + show, + projectId, + handleHide, +}: Props) { + return ( + + + + ) +} diff --git a/services/web/modules/git-bridge/frontend/js/card/components/git-modal.tsx b/services/web/modules/git-bridge/frontend/js/card/components/git-modal.tsx new file mode 100644 index 0000000000..41d14c5b9d --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/card/components/git-modal.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import getMeta from '@/utils/meta' +import GitFork from '@/shared/svgs/git-fork' +import { useProjectContext } from '@/shared/context/project-context' +import LeftMenuButton from '@/features/editor-left-menu/components/left-menu-button' +import GitModalWrapper from './git-modal-wrapper' + +function GitSyncButton() { + const { t } = useTranslation() + const { project } = useProjectContext() + const [show, setShow] = useState(false) + + const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled') + const projectId = project?._id + + if (!gitBridgeEnabled || !projectId) return null + + return ( + <> + setShow(true)} + icon={} + > + {t('git')} + + + setShow(false)} + projectId={projectId} + /> + + ) +} + +export default GitSyncButton diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/git-integration-widget.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/git-integration-widget.tsx new file mode 100644 index 0000000000..481d7e1d86 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/git-integration-widget.tsx @@ -0,0 +1,144 @@ +import { useState, useCallback, useEffect } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { debugConsole } from '@/utils/debugging' +import { postJSON, getJSON } from '@/infrastructure/fetch-json' +import useAsync from '@/shared/hooks/use-async' +import Notification from '@/shared/components/notification' +import OLButton from '@/shared/components/ol/ol-button' +import GitLogoOrange from '@/shared/svgs/git-logo-orange' +import TokenTable from './token-table' +import ExposeTokenModal from './modals/expose-token-modal' +import { Token } from '../../../../types/api' + +export default function GitIntegrationWidget() { + const { t } = useTranslation() + + const [tokens, setTokens] = useState([]) + const [showExposeTokenModal, setShowExposeTokenModal] = useState(false) + const [secretToken, setSecretToken] = useState(null) + + const { runAsync, isLoading, isError, reset } = useAsync() + + useEffect(() => { + runAsync(getJSON('/git-bridge/personal-access-tokens')) + .then((data: Token[]) => setTokens(data)) + .catch(debugConsole.error) + }, [runAsync]) + + const handleCreateToken = useCallback(() => { + runAsync(postJSON('/git-bridge/personal-access-tokens')) + .then((data: Token & { accessToken: string }) => { + const { accessToken, ...newToken } = data + setTokens(prev => [...prev, newToken]) + setSecretToken(accessToken) + setShowExposeTokenModal(true) + }) + .catch((err) => { + debugConsole.error(err) + setTimeout(() => reset(), 5000) + }) + }, [runAsync]) + + const handleDeleteToken = useCallback((id: string) => { + setTokens(prev => prev.filter(t => t._id !== id)) + }, []) + + const tokenCount = tokens.length + + return ( +
+ +
+ +
+ +
+ +
+

{t('git_integration')}

+
+ +

+ , + ]} + /> +

+ +

+ {t('your_git_access_tokens')} +

+ +

+ {t('your_git_access_info')} +

+ +
    + {tokenCount > 0 ? ( + <> +
  • {t('your_git_access_info_bullet_1')}
  • +
  • {t('your_git_access_info_bullet_2')}
  • + + ) : ( + <> +
  • + ]} + /> +
  • +
  • {t('your_git_access_info_bullet_4')}
  • +
  • {t('your_git_access_info_bullet_5')}
  • + + )} +
+ + + + {isError && ( +
+ +
+ )} + +
+ + {tokenCount === 0 && ( +
+ + {t('generate_token')} + +
+ )} + + {showExposeTokenModal && secretToken && ( + { + setShowExposeTokenModal(false) + setSecretToken(null) + }} + /> + )} +
+ ) +} diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/modals/delete-token-modal.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/modals/delete-token-modal.tsx new file mode 100644 index 0000000000..b530d1083b --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/modals/delete-token-modal.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from 'react-i18next' +import { deleteJSON } from '@/infrastructure/fetch-json' +import { + OLModal, + OLModalHeader, + OLModalTitle, + OLModalBody, + OLModalFooter, +} from '@/shared/components/ol/ol-modal' +import Notification from '@/shared/components/notification' +import OLButton from '@/shared/components/ol/ol-button' +import useAsync from '@/shared/hooks/use-async' +import { debugConsole } from '@/utils/debugging' + +type Props = { + show: boolean + handleHide: () => void + tokenId: string + onDeleted: (id: string) => void +} + +export default function DeleteTokenModal({ + show, + handleHide, + tokenId, + onDeleted, +}: Props) { + const { t } = useTranslation() + + const { isLoading, isError, runAsync, reset } = useAsync() + + const handleClose = () => { + reset() + handleHide() + } + + const handleDelete = () => { + runAsync( + deleteJSON(`/git-bridge/personal-access-tokens/${tokenId}`, { + body: { _csrf: window.csrfToken }, + }) + ) + .then(() => { + onDeleted(tokenId) + handleClose() + }) + .catch(debugConsole.error) + } + + return ( + + + {t('delete_authentication_token')} + + + +

{t('delete_authentication_token_info')}

+ + {isError && ( +
+ +
+ )} +
+ + + + {t('cancel')} + + + + {t('delete_token')} + + +
+ ) +} diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/modals/expose-token-modal.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/modals/expose-token-modal.tsx new file mode 100644 index 0000000000..f59c1a36df --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/modals/expose-token-modal.tsx @@ -0,0 +1,80 @@ +import { Trans, useTranslation } from 'react-i18next' +import { + OLModal, + OLModalHeader, + OLModalTitle, + OLModalBody, + OLModalFooter, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' +import { CopyToClipboard } from '@/shared/components/copy-to-clipboard' + +type Props = { + secretToken: string + handleHide: () => void +} + +export default function ExposeTokenModal({ + secretToken, + handleHide, +}: Props) { + const { t } = useTranslation() + + return ( + + + + {t('git_authentication_token')} + + + + +

+ {t('git_authentication_token_create_modal_info_1')} +

+ +
+
+ + {secretToken} + + +
+
+ +

+ , + , + ]} + /> +

+
+ + + + {t('close')} + + +
+ ) +} diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/token-table-footer.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/token-table-footer.tsx new file mode 100644 index 0000000000..ca48ffc9cc --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/token-table-footer.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from 'react-i18next' +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 Cell from '@/features/settings/components/emails/cell' + +type Props = { + tokenCount: number + limitReached: boolean + onCreateToken: () => void + isLoading: boolean +} + +export default function TokenTableFooter({ + tokenCount, + limitReached, + onCreateToken, + isLoading, +}: Props) { + const { t } = useTranslation() + + if (tokenCount === 0) return null + + return ( + <> +
+
+ + + + {limitReached ? ( +

{t('token_limit_reached')}

+ ) : ( + + {t('add_another_token')} + + )} +
+
+
+
+ + ) +} diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/token-table-header.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/token-table-header.tsx new file mode 100644 index 0000000000..a72aafc6e8 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/token-table-header.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import Cell from '@/features/settings/components/emails/cell' + +export default function TokenTableHeader() { + const { t } = useTranslation() + + return ( + <> + + + {t('token')} + + + + {t('created_at')} + + + + {t('last_used')} + + + + {t('expires')} + + + +
+
+ + ) +} diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/token-table-row.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/token-table-row.tsx new file mode 100644 index 0000000000..ddaedc6386 --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/token-table-row.tsx @@ -0,0 +1,64 @@ +import moment from 'moment' +import { useTranslation } from 'react-i18next' +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 Cell from '@/features/settings/components/emails/cell' +import { Token } from '../../../../types/api' + +type Props = { + token: Token + handleDeleteClick: (id: string) => void +} + +function TokenTableRow({ token, handleDeleteClick }: Props) { + const { t } = useTranslation() + + const created = moment(token.created_at).format('Do MMM YYYY') + const lastUsed = token.lastUsedAt + ? moment(token.lastUsedAt).format('Do MMM YYYY') + : t('never') + const expires = moment(token.expiresAt).format('Do MMM YYYY') + + const handleClick = () => handleDeleteClick(token._id) + + return ( + + + {token.accessTokenPartial + '************'} + + + + {created} + + + + {lastUsed} + + + + {expires} + + + + + + + + + + + ) +} + +export default TokenTableRow diff --git a/services/web/modules/git-bridge/frontend/js/widget/components/token-table.tsx b/services/web/modules/git-bridge/frontend/js/widget/components/token-table.tsx new file mode 100644 index 0000000000..69cc29ec8b --- /dev/null +++ b/services/web/modules/git-bridge/frontend/js/widget/components/token-table.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import DeleteTokenModal from './modals/delete-token-modal' +import TokenTableHeader from './token-table-header' +import TokenTableRow from './token-table-row' +import TokenTableFooter from './token-table-footer' +import { Token } from '../../../../types/api' + +const MAX_TOKENS = 10 + +type Props = { + tokens: Token[] + onCreateToken: () => void + onDeleteToken: (id: string) => void +} + +export default function TokenTable({ + tokens, + onCreateToken, + onDeleteToken, +}: Props) { + const { t } = useTranslation() + + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [selectedTokenId, setSelectedTokenId] = useState('') + + const handleDeleteClick = (id: string) => { + setSelectedTokenId(id) + setShowDeleteModal(true) + } + + const tokenCount = tokens.length + const limitReached = tokenCount >= MAX_TOKENS + + return ( + <> + {tokenCount > 0 && } + + {tokens.map((token) => ( + + ))} + + + + setShowDeleteModal(false)} + onDeleted={(id: string) => { + onDeleteToken(id) + setShowDeleteModal(false) + }} + /> + + ) +} diff --git a/services/web/modules/git-bridge/index.mjs b/services/web/modules/git-bridge/index.mjs new file mode 100644 index 0000000000..46f60b2a60 --- /dev/null +++ b/services/web/modules/git-bridge/index.mjs @@ -0,0 +1,48 @@ +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import { db } from '../../app/src/infrastructure/mongodb.mjs' +import Modules from '../../app/src/infrastructure/Modules.mjs' +import GitBridgeRouter from './app/src/GitBridgeRouter.mjs' + +let GitBridgeModule = {} + +if (process.env.GIT_BRIDGE_ENABLED === 'true') { + logger.debug({}, 'Enabling git-bridge module') + + Settings.enableGitBridge = true + + // Delete all user's git-bridge tokens on user expire (hook 'expireDeletedUser') + Modules.hooks.attach('expireDeletedUser', async userId => { + try { + const query = { + user_id: userId, + scope: /\bgit_bridge\b/, + type: 'personal_access_token' + } + await db.oauthAccessTokens.deleteMany(query) + } catch (err) { + logger.warn({ userId, err }, 'on user expire: failed deleting git-bridge tokens') + } + }) + // Delete project from /data/git-bridge on project expire (hook 'projectExpired') + Modules.hooks.attach('projectExpired', async projectId => { + const gitBridgeApiBaseUrl = process.env.GIT_BRIDGE_API_BASE_URL || + `http://${process.env.GIT_BRIDGE_HOST || 'git-bridge'}:${ + process.env.GIT_BRIDGE_PORT || '8000' + }/api` + try { + const res = await fetch(`${gitBridgeApiBaseUrl}/projects/${projectId}`, { + method: 'DELETE', + }) + if (!res.ok) throw new Error(`error status: ${res.status}`) + + } catch (err) { + logger.warn({ projectId, err }, 'on project expire: failed deleting project in git-bridge') + } + }) + GitBridgeModule = { + router: GitBridgeRouter, + } +} + +export default GitBridgeModule diff --git a/services/web/modules/git-bridge/types/api.d.ts b/services/web/modules/git-bridge/types/api.d.ts new file mode 100644 index 0000000000..e505d01216 --- /dev/null +++ b/services/web/modules/git-bridge/types/api.d.ts @@ -0,0 +1,7 @@ +export type Token = { + _id: string + accessTokenPartial: string + created_at: string + lastUsedAt?: string + expiresAt: string +}