Git Bridge: Add git integration

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

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
}