Git Bridge: Add git integration

This commit is contained in:
yu-i-i
2026-03-14 02:33:19 +01:00
parent 4239c2f3a8
commit 486460f599
25 changed files with 1571 additions and 5 deletions

View File

@@ -1040,7 +1040,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,
@@ -1053,7 +1058,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: [],
@@ -1090,7 +1100,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: [],
settingsModalEditorTabSections: [],
errorLogsComponents: [],
@@ -1111,6 +1126,7 @@ module.exports = {
'authentication/oidc',
'admin-tools', // import after authentication
'template-gallery',
'git-bridge',
],
viewIncludes: {},
@@ -1124,7 +1140,6 @@ module.exports = {
'app/views/project/ide-react': [`img-src 'self' data: blob:`],
},
},
unsupportedBrowsers: {
ie: '<=11',
safari: '<=14',

View File

@@ -667,8 +667,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.</0> Für weitere Anweisungen zur Verwendung von Anmelde-Tokens, besuche unsere <1>Hilfe-Seite</1>.",
"git_bridge_modal_click_generate": "Klicke jetzt auf <strong>Token generieren</strong> 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</0>.",
"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</0>.",
"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</0>.",
"github_commit_message_placeholder": "Commit-Meldung für Änderungen die in __appName__ gemacht wurden",
@@ -1548,7 +1551,9 @@
"to_change_access_permissions": "Um Zugriffsberechtigungen zu ändern, wende dich bitte an den Projektinhaber",
"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_confirm_code_resend_attempts": "Zu viele Versuche. Bitte warte 1 Minute und versuche es dann erneut.",
"too_many_confirm_code_verification_attempts": "Zu viele Verifizierungsversuche. Bitte warte 1 Minute und versuche es dann erneut.",
@@ -1717,6 +1722,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_account_is_managed_by_your_group_admin": "Dein Konto wird von deinem Gruppenadministrator verwaltet. Du kannst deine E-Mail-Adresse nicht ändern oder löschen.",
"your_affiliation_is_confirmed": "Deine Zugehörigkeit zu <0>__institutionName__</0> ist bestätigt.",
"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</0>-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__</0>.",

View File

@@ -52,6 +52,7 @@
"activation_link": "Ссылка для активации",
"activation_token_expired": "Срок действия Вашего ключа истёк. Вам необходимо запросить новый ключ активации.",
"add": "Добавить",
"add_another_token": "Добавить ещё один токен",
"added": "добавлены",
"adding": "Добавление",
"address": "Адрес",
@@ -99,6 +100,7 @@
"clear_sessions_success": "Сессии завершены",
"clearing": "Очистка",
"click_here_to_view_sl_in_lng": "Кликните здесь, для использования __appName__ на <0>__lngName__</0>",
"clone_with_git": "Клонировать при помощи git",
"close": "Закрыть",
"clsi_maintenance": "На сервере компиляции проводятся ремонтные работы, и он будет вскоре доступен снова.",
"cn": "Китайский (упрощённый)",
@@ -130,6 +132,7 @@
"create_account": "Создать аккаунт",
"create_new_subscription": "Создать новую подписку",
"create_project_in_github": "Создать проект на GitHub",
"created_at": "Создано",
"creating": "Создание",
"credit_card": "банковская карта",
"cs": "Чешский",
@@ -170,6 +173,7 @@
"es": "Испанский",
"every": "каждый",
"example_project": "Использовать пример",
"expires": "Истекает",
"expiry": "Срок действия",
"export_project_to_github": "Экспорт проекта на GitHub",
"failed_to_publish_as_a_template": "Не удалось создать шаблон.",
@@ -188,8 +192,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_public_description": "Этот репозиторий может просмотреть каждый. Вы выбираете, кто может править.",
@@ -237,6 +247,7 @@
"language": "Язык",
"last_modified": "Последнее изменение",
"last_name": "Фамилия",
"last_used": "Последнее использование",
"latex_templates": "Шаблоны",
"learn_more": "Узнать больше",
"leave_group": "Покинуть группу",
@@ -440,7 +451,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": "Исходный код",
"start_free_trial": "Попробовать бесплатно!",
"state": "Состояние",
@@ -486,6 +498,8 @@
"timedout": "Время ожидания истекло",
"title": "Название",
"to_modify_your_subscription_go_to": "Для изменения подписки перейдите по ссылке:",
"token": "Токен",
"token_limit_reached": "Вы достигли лимита в 10 токенов. Чтобы создать новый токен аутентификации, удалите один из имеющихся.",
"too_many_files_uploaded_throttled_short_period": "Слишком много файлов загружено за раз - на короткое время загрузка была приостановлена.",
"too_many_login_requests_2_mins": "Было предпринято слишком много попыток входа. Пожалуйста, подождите 2 минуты, прежде чем пробовать снова",
"too_recently_compiled": "Этот проект был скомпилирован совсем недавно, поэтому компиляция была пропущена.",
@@ -545,6 +559,13 @@
"you": "Вы",
"you_cant_overwrite_it": "Перезаписать нельзя.",
"you_have_added_x_of_group_size_y": "Вы добавили <0>__addedUsersSize__</0> из <1>__groupSize__</1> доступных участников",
"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>Создать токен</0>.",
"your_git_access_info_bullet_4": "После того как вы создадите токен, у вас больше не будет возможности увидеть его снова. Пожалуйста, скопируйте его и сохраните в безопасности.",
"your_git_access_info_bullet_5": "Ранее созданные токены будут отображаться здесь.",
"your_git_access_tokens": "Ваши токены аутентификации Git",
"your_plan": "Ваш тариф",
"your_projects": "Созданные мной",
"your_sessions": "Ваши сессии",

View File

@@ -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)
}
}
}

View File

@@ -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),
}

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
)
}
}

View File

@@ -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 (
<>
<IntegrationCard
title={t('git')}
description={t('git_clone_this_project')}
icon={<GitLogoOrange size={32} />}
onClick={() => setShow(true)}
/>
<GitModalWrapper
show={show}
handleHide={() => setShow(false)}
projectId={projectId}
/>
</>
)
}
export default GitSyncCard

View File

@@ -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 (
<>
<OLModalHeader closeButton>
<OLModalTitle>{t('clone_with_git')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>{t('git_bridge_modal_git_clone_your_project')}</p>
<div className="git-bridge-copy">
<span aria-label={t('git_clone_project_command')}>
<code>
{gitCloneCommand}
</code>
</span>
<CopyToClipboard
content={gitCloneCommand}
tooltipId="git-copy-clone-project-command-tooltip"
/>
</div>
<Trans
i18nKey="git_bridge_modal_use_previous_token"
components={[
<a
href="/learn/how-to/Git_integration_authentication_tokens"
target="_blank"
rel="noreferrer noopener"
/>,
]}
/>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleHide}
>
{t('close')}
</OLButton>
<OLButton
variant="primary"
href="/user/settings"
target="_blank"
rel="noreferrer noopener"
>
{t('go_to_settings')}
</OLButton>
</OLModalFooter>
</>
)
}

View File

@@ -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 (
<OLModal
show={show}
onHide={handleHide}
id="git-sync-modal"
backdrop="static"
size="lg"
>
<GitModalContent
projectId={projectId}
handleHide={handleHide}
/>
</OLModal>
)
}

View File

@@ -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 (
<>
<LeftMenuButton
variant="link"
className="left-menu-button"
onClick={() => setShow(true)}
icon={<GitFork />}
>
{t('git')}
</LeftMenuButton>
<GitModalWrapper
show={show}
handleHide={() => setShow(false)}
projectId={projectId}
/>
</>
)
}
export default GitSyncButton

View File

@@ -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<Token[]>([])
const [showExposeTokenModal, setShowExposeTokenModal] = useState(false)
const [secretToken, setSecretToken] = useState<string | null>(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 (
<div className="settings-widget-container">
<div className="linking-icon-fixed-position">
<GitLogoOrange />
</div>
<div className="description-container">
<div className="title-row">
<h4>{t('git_integration')}</h4>
</div>
<p className="small">
<Trans
i18nKey="git_integration_info"
components={[
<a
key="git-link"
href="/learn/how-to/Git_Integration"
target="_blank"
rel="noreferrer noopener"
/>,
]}
/>
</p>
<h4 className="ui-heading">
{t('your_git_access_tokens')}
</h4>
<p className="small">
{t('your_git_access_info')}
</p>
<ul className="small">
{tokenCount > 0 ? (
<>
<li>{t('your_git_access_info_bullet_1')}</li>
<li>{t('your_git_access_info_bullet_2')}</li>
</>
) : (
<>
<li>
<Trans
i18nKey="your_git_access_info_bullet_3"
components={[<strong key="strong" />]}
/>
</li>
<li>{t('your_git_access_info_bullet_4')}</li>
<li>{t('your_git_access_info_bullet_5')}</li>
</>
)}
</ul>
<TokenTable
tokens={tokens}
onCreateToken={handleCreateToken}
onDeleteToken={handleDeleteToken}
/>
{isError && (
<div className="notification-list">
<Notification
type="error"
content={t('something_went_wrong_server')}
/>
</div>
)}
</div>
{tokenCount === 0 && (
<div>
<OLButton
variant="secondary"
id="generate-token-button"
onClick={handleCreateToken}
disabled={isLoading || isError}
>
{t('generate_token')}
</OLButton>
</div>
)}
{showExposeTokenModal && secretToken && (
<ExposeTokenModal
secretToken={secretToken}
handleHide={() => {
setShowExposeTokenModal(false)
setSecretToken(null)
}}
/>
)}
</div>
)
}

View File

@@ -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 (
<OLModal show={show} onHide={handleClose} backdrop="static">
<OLModalHeader>
<OLModalTitle>{t('delete_authentication_token')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>{t('delete_authentication_token_info')}</p>
{isError && (
<div className="notification-list">
<Notification
type="error"
content={t('something_went_wrong_server')}
/>
</div>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={handleClose} disabled={isLoading}>
{t('cancel')}
</OLButton>
<OLButton variant="danger" onClick={handleDelete} disabled={isLoading}>
{t('delete_token')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -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 (
<OLModal
show
onHide={handleHide}
backdrop="static"
id="create-token-modal"
>
<OLModalHeader closeButton>
<OLModalTitle>
{t('git_authentication_token')}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
{t('git_authentication_token_create_modal_info_1')}
</p>
<div className="text-center">
<div className="git-bridge-copy">
<span aria-label={t('git_authentication_token')}>
<code>{secretToken}</code>
</span>
<CopyToClipboard
content={secretToken}
tooltipId="git-auth-token-copy"
kind="text"
/>
</div>
</div>
<p>
<Trans
i18nKey="git_authentication_token_create_modal_info_2"
components={[
<strong key="strong" />,
<a
key="link"
href="/learn/how-to/Git_integration_authentication_tokens"
target="_blank"
rel="noreferrer noopener"
/>,
]}
/>
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="primary"
onClick={handleHide}
>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -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 (
<>
<div className="horizontal-divider" />
<div className="affiliations-table-row-highlighted">
<OLRow className="small">
<OLCol lg={12}>
<Cell>
{limitReached ? (
<p>{t('token_limit_reached')}</p>
) : (
<OLButton
variant="link"
className="btn-inline-link"
onClick={onCreateToken}
disabled={isLoading}
>
{t('add_another_token')}
</OLButton>
)}
</Cell>
</OLCol>
</OLRow>
</div>
</>
)
}

View File

@@ -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 (
<>
<OLRow className="small">
<OLCol lg={4} className="linking-git-bridge-table-cell">
<Cell><strong>{t('token')}</strong></Cell>
</OLCol>
<OLCol lg={2} className="linking-git-bridge-table-cell">
<Cell><strong>{t('created_at')}</strong></Cell>
</OLCol>
<OLCol lg={2} className="linking-git-bridge-table-cell">
<Cell><strong>{t('last_used')}</strong></Cell>
</OLCol>
<OLCol lg={3} className="linking-git-bridge-table-cell">
<Cell><strong>{t('expires')}</strong></Cell>
</OLCol>
</OLRow>
<div className="horizontal-divider" />
<div className="horizontal-divider" />
</>
)
}

View File

@@ -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 (
<OLRow className="small">
<OLCol lg={4} className="linking-git-bridge-table-cell">
<Cell>{token.accessTokenPartial + '************'}</Cell>
</OLCol>
<OLCol lg={2} className="linking-git-bridge-table-cell">
<Cell>{created}</Cell>
</OLCol>
<OLCol lg={2} className="linking-git-bridge-table-cell">
<Cell>{lastUsed}</Cell>
</OLCol>
<OLCol lg={3} className="linking-git-bridge-table-cell">
<Cell>{expires}</Cell>
</OLCol>
<OLCol lg={1} className="linking-git-bridge-table-cell">
<Cell>
<OLButton
className="linking-git-bridge-revoke-button icon-button-small"
variant="secondary"
aria-label={t('remove')}
onClick={handleClick}
>
<span
className="material-symbols icon-small"
aria-hidden="true"
>
delete
</span>
</OLButton>
</Cell>
</OLCol>
</OLRow>
)
}
export default TokenTableRow

View File

@@ -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 && <TokenTableHeader />}
{tokens.map((token) => (
<TokenTableRow
key={token._id}
token={token}
handleDeleteClick={handleDeleteClick}
/>
))}
<TokenTableFooter
tokenCount={tokenCount}
limitReached={limitReached}
onCreateToken={onCreateToken}
isLoading={false}
/>
<DeleteTokenModal
show={showDeleteModal}
tokenId={selectedTokenId}
handleHide={() => setShowDeleteModal(false)}
onDeleted={(id: string) => {
onDeleteToken(id)
setShowDeleteModal(false)
}}
/>
</>
)
}

View File

@@ -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

View File

@@ -0,0 +1,7 @@
export type Token = {
_id: string
accessTokenPartial: string
created_at: string
lastUsedAt?: string
expiresAt: string
}