mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Git Bridge: Add git integration
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
261
services/web/modules/git-bridge/app/src/GitBridgeController.mjs
Normal file
261
services/web/modules/git-bridge/app/src/GitBridgeController.mjs
Normal 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),
|
||||
}
|
||||
153
services/web/modules/git-bridge/app/src/GitBridgeHandler.mjs
Normal file
153
services/web/modules/git-bridge/app/src/GitBridgeHandler.mjs
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
125
services/web/modules/git-bridge/app/src/GitBridgePATManager.mjs
Normal file
125
services/web/modules/git-bridge/app/src/GitBridgePATManager.mjs
Normal 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
|
||||
56
services/web/modules/git-bridge/app/src/GitBridgeRouter.mjs
Normal file
56
services/web/modules/git-bridge/app/src/GitBridgeRouter.mjs
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
48
services/web/modules/git-bridge/index.mjs
Normal file
48
services/web/modules/git-bridge/index.mjs
Normal 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
|
||||
7
services/web/modules/git-bridge/types/api.d.ts
vendored
Normal file
7
services/web/modules/git-bridge/types/api.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export type Token = {
|
||||
_id: string
|
||||
accessTokenPartial: string
|
||||
created_at: string
|
||||
lastUsedAt?: string
|
||||
expiresAt: string
|
||||
}
|
||||
Reference in New Issue
Block a user