mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
Github Sync: initial commit
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
|
||||
|
||||
const { Schema } = mongoose
|
||||
const { ObjectId } = Schema
|
||||
|
||||
export const GitHubSyncProjectStatesSchema = new Schema(
|
||||
{
|
||||
projectId: { type: ObjectId, ref: 'Project', required: true, unique: true },
|
||||
repoFullName: { type: String, required: true },
|
||||
defaultBranchName: { type: String, default: null },
|
||||
mergeStatus: { type: String, enum: ['clean', 'conflict', 'diverged'], default: 'clean' },
|
||||
lastSyncCommit: { type: String, default: null },
|
||||
lastSyncVersion: { type: Number, default: null },
|
||||
unmergedBranchName: { type: String, default: null },
|
||||
unmergedBranchHead: { type: String, default: null },
|
||||
conflictVersion: { type: Number, default: null },
|
||||
},
|
||||
{ collection: 'githubSyncProjectStates', minimize: false }
|
||||
)
|
||||
|
||||
export const GitHubSyncProjectStates = mongoose.model(
|
||||
'GitHubSyncProjectStates',
|
||||
GitHubSyncProjectStatesSchema,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import mongoose from "../../../../app/src/infrastructure/Mongoose.mjs"
|
||||
|
||||
const { Schema } = mongoose
|
||||
const { ObjectId } = Schema
|
||||
|
||||
export const GitHubSyncUserCredentialsSchema = new Schema(
|
||||
{
|
||||
userId: { type: ObjectId, ref: 'User', required: true, unique: true },
|
||||
github: { type: String, required: true },
|
||||
},
|
||||
{ collection: 'githubSyncUserCredentials', minimize: false }
|
||||
)
|
||||
|
||||
export const GitHubSyncUserCredentials = mongoose.model(
|
||||
'GitHubSyncUserCredentials',
|
||||
GitHubSyncUserCredentialsSchema,
|
||||
)
|
||||
@@ -0,0 +1,495 @@
|
||||
import AbortError from 'node-fetch'
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
|
||||
import {
|
||||
fetchJson,
|
||||
fetchJsonWithResponse,
|
||||
fetchStreamWithResponse,
|
||||
fetchNothing,
|
||||
fetchStream,
|
||||
RequestFailedError,
|
||||
} from '@overleaf/fetch-utils'
|
||||
|
||||
import {
|
||||
InvalidTokenError,
|
||||
NotFoundError,
|
||||
GitConflictError,
|
||||
RateLimitError,
|
||||
ProviderRequestError,
|
||||
PermissionDeniedError,
|
||||
AlreadyExistsError,
|
||||
} from './GitSyncErrors.mjs'
|
||||
|
||||
const GITHUB_URL = 'https://github.com'
|
||||
const GITHUB_API_BASE = 'https://api.github.com'
|
||||
const GITHUB_GRAPHQL = 'https://api.github.com/graphql'
|
||||
const MAX_PER_PAGE = 100 // GitHub REST API limit
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 60 * 1000
|
||||
const REQUEST_LONG_TIMEOUT_MS = 600 * 1000
|
||||
|
||||
const maxConcurrency = process.env.GITHUB_API_MAX_CONCURRENCY || 5
|
||||
|
||||
function buildHeaders(token) {
|
||||
return {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'Overleaf-CEP-GitHub-Sync',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling
|
||||
function isRateLimitError(response) {
|
||||
const remaining = response.headers.get('x-ratelimit-remaining')
|
||||
const retryAfter = response.headers.get('retry-after')
|
||||
|
||||
return remaining === '0' || retryAfter != null
|
||||
}
|
||||
|
||||
function isRepositoryAlreadyExistsError(err) {
|
||||
if (err.response?.status !== 422) {
|
||||
return false
|
||||
}
|
||||
|
||||
let body = err.body
|
||||
|
||||
if (typeof body === 'string') {
|
||||
try {
|
||||
body = JSON.parse(body)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const errors = body?.errors
|
||||
|
||||
if (!Array.isArray(errors)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return errors.some(error =>
|
||||
error?.resource === 'Repository' &&
|
||||
error?.field === 'name' &&
|
||||
typeof error?.message === 'string' &&
|
||||
error.message.toLowerCase().includes('already exists')
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeGitHubError(err, operation) {
|
||||
logger.error({ operation }, 'GitHub API request failed')
|
||||
|
||||
if (err.name === 'AbortError') {
|
||||
throw new ProviderRequestError('GitHub request timed out', { status: 504 }, err)
|
||||
}
|
||||
|
||||
if (!(err instanceof RequestFailedError)) {
|
||||
throw new ProviderRequestError('Something wrong with GitHub request', { status: 500 }, err)
|
||||
}
|
||||
|
||||
const status = err.response?.status || 500
|
||||
|
||||
if (status === 401) {
|
||||
throw new InvalidTokenError('Invalid token', { status }, err)
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
throw new NotFoundError('Not found', { status }, err)
|
||||
}
|
||||
|
||||
if (status === 409) {
|
||||
throw new GitConflictError()
|
||||
}
|
||||
|
||||
if (status === 403 || status === 429) {
|
||||
if (isRateLimitError(err.response)) throw new RateLimitError('Rate limit exeeded', { status: 429 }, err)
|
||||
throw new PermissionDeniedError('Permission denied', { status: 403 }, err)
|
||||
}
|
||||
|
||||
if (status === 422) {
|
||||
if (isRepositoryAlreadyExistsError(err)) {
|
||||
throw new AlreadyExistsError('Repository already exists', { status }, err)
|
||||
}
|
||||
}
|
||||
|
||||
throw new ProviderRequestError('GitHub request failed', { status }, err)
|
||||
}
|
||||
|
||||
// wrappers
|
||||
function fetchGitHubJson(url, options, operation) {
|
||||
return fetchJson(url, options).catch(err => {
|
||||
normalizeGitHubError(err, operation)
|
||||
})
|
||||
}
|
||||
|
||||
function fetchGitHubJsonWithResponse(url, options, operation) {
|
||||
return fetchJsonWithResponse(url, options).catch(err => {
|
||||
normalizeGitHubError(err, operation)
|
||||
})
|
||||
}
|
||||
|
||||
function fetchGitHubStreamWithResponse(url, options, operation) {
|
||||
return fetchStreamWithResponse(url, options).catch(err => {
|
||||
normalizeGitHubError(err, operation)
|
||||
})
|
||||
}
|
||||
|
||||
function fetchGitHubNothing(url, options, operation) {
|
||||
return fetchNothing(url, options).catch(err => {
|
||||
normalizeGitHubError(err, operation)
|
||||
})
|
||||
}
|
||||
|
||||
function fetchGitHubStream(url, options, operation) {
|
||||
return fetchStream(url, options).catch(err => {
|
||||
normalizeGitHubError(err, operation)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------- exports ------------------------------- //
|
||||
|
||||
// OAuth
|
||||
function getOAuth2Url() {
|
||||
const oAuthUrl = new URL(`${GITHUB_URL}/login/oauth/authorize`)
|
||||
oAuthUrl.searchParams.append('client_id', Settings.githubSync.clientID)
|
||||
oAuthUrl.searchParams.append('redirect_uri', Settings.githubSync.callbackURL)
|
||||
oAuthUrl.searchParams.append('scope', 'read:org,repo,workflow')
|
||||
return oAuthUrl
|
||||
}
|
||||
|
||||
function exchangeCodeForToken(code) {
|
||||
return fetchGitHubJson(`${GITHUB_URL}/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(),
|
||||
json: {
|
||||
code,
|
||||
client_id: Settings.githubSync.clientID,
|
||||
client_secret: Settings.githubSync.clientSecret,
|
||||
redirect_uri: Settings.githubSync.callbackURL,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'exchangeCodeForToken').then(r => r.access_token)
|
||||
}
|
||||
|
||||
function revokeToken(token) {
|
||||
const { clientID, clientSecret } = Settings.githubSync
|
||||
return fetchGitHubNothing(`${GITHUB_API_BASE}/applications/${clientID}/token`, {
|
||||
method: 'DELETE',
|
||||
headers: buildHeaders(),
|
||||
basicAuth: {
|
||||
user: clientID,
|
||||
password: clientSecret,
|
||||
},
|
||||
json: { access_token: token },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, 'revokeToken')
|
||||
}
|
||||
|
||||
// user, orgs, permissions
|
||||
function getUser(token) {
|
||||
return fetchGitHubJson(`${GITHUB_API_BASE}/user`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, 'getUser').then(({ id, login, name }) => ({ id, login, name }))
|
||||
}
|
||||
|
||||
/*
|
||||
async function getUserAndOrgsREST(token) {
|
||||
const user = await fetchGitHubJson(`${GITHUB_API_BASE}/user`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'getUserAndOrgs:user')
|
||||
|
||||
const page = 1
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
per_page: MAX_PER_PAGE.toString(),
|
||||
})
|
||||
const orgs = await fetchGitHubJson(`${GITHUB_API_BASE}/user/orgs?${params}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'getUserAndOrgs:orgs')
|
||||
|
||||
return {
|
||||
user: user.login,
|
||||
orgs: orgs.map(o => o.login)
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
async function getUserAndOrgs(token) {
|
||||
const query = `
|
||||
query {
|
||||
viewer {
|
||||
login
|
||||
organizations(first: 100) {
|
||||
nodes {
|
||||
login
|
||||
viewerCanCreateRepositories
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const json = await fetchGitHubJson(GITHUB_GRAPHQL, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
json: { query }
|
||||
}, 'getUserAndOrgs')
|
||||
|
||||
const viewer = json.data.viewer
|
||||
|
||||
return {
|
||||
user: viewer.login,
|
||||
orgs: viewer.organizations.nodes.map(o => o.login)
|
||||
}
|
||||
}
|
||||
|
||||
function getPushPermission(token, repoFullName) {
|
||||
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, 'getPushPrmission').then(repo => (repo.permissions?.push === true))
|
||||
}
|
||||
|
||||
// repos
|
||||
function createRepo(token, { name, description, isPublic, org }) {
|
||||
const url = org ? `${GITHUB_API_BASE}/orgs/${org}/repos` : `${GITHUB_API_BASE}/user/repos`
|
||||
return fetchGitHubJson(url, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token),
|
||||
json: {
|
||||
name,
|
||||
description,
|
||||
private: !isPublic,
|
||||
auto_init: true,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
|
||||
}, 'createRepo')
|
||||
}
|
||||
|
||||
async function _listUserReposPage(token, page) {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
per_page: MAX_PER_PAGE.toString(),
|
||||
})
|
||||
|
||||
const { json, response } = await fetchGitHubJsonWithResponse(`${GITHUB_API_BASE}/user/repos?${params}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}, '_listUserReposPage')
|
||||
|
||||
const link = response.headers.get('link') || ''
|
||||
const hasNext = link.includes('rel="next"')
|
||||
|
||||
return {
|
||||
repos: json.map(r => ({
|
||||
name: r.name,
|
||||
fullName: r.full_name,
|
||||
defaultBranchName: r.default_branch,
|
||||
})),
|
||||
hasNext,
|
||||
}
|
||||
}
|
||||
|
||||
async function listUserRepos(token) {
|
||||
let all = []
|
||||
let page = 1
|
||||
|
||||
while (true) {
|
||||
const { repos, hasNext } = await _listUserReposPage(token, page)
|
||||
all = all.concat(repos)
|
||||
if (!hasNext) break
|
||||
page++
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
// Git (blobs / trees / commits)
|
||||
function uploadBlob(token, repoFullName, buffer) {
|
||||
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/blobs`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token),
|
||||
json: {
|
||||
content: buffer,
|
||||
encoding: 'base64',
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
|
||||
}, 'uploadBlob').then(r => r.sha)
|
||||
}
|
||||
|
||||
function getBlobStream(token, repoFullName, ref, path) {
|
||||
const encodedPath = encodeURIComponent(path).replace(/%2F/g, '/')
|
||||
|
||||
return fetchGitHubStream(`${GITHUB_API_BASE}/repos/${repoFullName}/contents/${encodedPath}?ref=${ref}`, {
|
||||
headers: {
|
||||
...buildHeaders(token),
|
||||
Accept: 'application/vnd.github.raw',
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS),
|
||||
}, 'getBlobStream')
|
||||
}
|
||||
|
||||
function createTree(token, repoFullName, entries, base_tree) {
|
||||
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/trees`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token),
|
||||
json: {
|
||||
tree: entries.map(e => ({
|
||||
path: e.path,
|
||||
sha: e.sha,
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
})),
|
||||
...(base_tree && { base_tree }),
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'createTree').then(r => r.sha)
|
||||
}
|
||||
|
||||
function createCommit(token, repoFullName, { tree, message, parents = [] }) {
|
||||
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/commits`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token),
|
||||
json: {
|
||||
tree,
|
||||
message,
|
||||
parents,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'createCommit').then(r => r.sha)
|
||||
}
|
||||
|
||||
function getCommitTree(token, repoFullName, commit) {
|
||||
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/commits/${commit}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'getCommitTree').then(r => r.tree.sha)
|
||||
}
|
||||
|
||||
function listBlobsAtCommit(token, repoFullName, commit) {
|
||||
// can use commit sha here instead of tree sha
|
||||
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/trees/${commit}?recursive=1`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
|
||||
}, 'listBlobsAtCommit').then(r =>
|
||||
(r.tree || [])
|
||||
.filter(entry => entry.type === 'blob')
|
||||
.map(entry => ({
|
||||
sha: entry.sha,
|
||||
path: entry.path
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
async function listNewCommitsWithStatus(token, fullName, branchName, fromCommit) {
|
||||
const url = `${GITHUB_API_BASE}/repos/${fullName}/compare/${fromCommit}...${encodeURIComponent(branchName)}`
|
||||
const data = await fetchGitHubJson(url, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
|
||||
}, 'listNewCommitsWithStatus')
|
||||
|
||||
const commits = (data.commits || []).map(c => ({
|
||||
message: c.commit?.message || '',
|
||||
author: {
|
||||
name: c.commit?.author?.name || '',
|
||||
email: c.commit?.author?.email || '',
|
||||
date: c.commit?.author?.date || '',
|
||||
},
|
||||
sha: c.sha,
|
||||
}))
|
||||
const diverged = (data.status === 'diverged' || data.status === 'behind')
|
||||
return { commits, diverged }
|
||||
}
|
||||
|
||||
// branches
|
||||
function getBranchHead(token, repoFullName, branchName) {
|
||||
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/ref/heads/${encodeURIComponent(branchName)}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'getBranchHead').then(r => r.object.sha)
|
||||
}
|
||||
|
||||
function createBranch(token, repoFullName, branchName, sha) {
|
||||
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/refs`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token),
|
||||
json: {
|
||||
ref: `refs/heads/${branchName}`,
|
||||
sha,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'createBranch')
|
||||
}
|
||||
|
||||
function updateBranch(token, repoFullName, branchName, sha, force = false) {
|
||||
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/git/refs/heads/${encodeURIComponent(branchName)}`, {
|
||||
method: 'PATCH',
|
||||
headers: buildHeaders(token),
|
||||
json: { sha, force },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'updateBranch')
|
||||
}
|
||||
|
||||
function deleteBranch(token, repoFullName, branchName) {
|
||||
return fetchGitHubNothing(`${GITHUB_API_BASE}/repos/${repoFullName}/git/refs/heads/${encodeURIComponent(branchName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'deleteBranch')
|
||||
}
|
||||
|
||||
// merge / compare
|
||||
function mergeBranch(token, repoFullName, base, head) {
|
||||
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/merges`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(token),
|
||||
json: { base, head },
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS)
|
||||
}, 'mergeBranch').then(r => r.sha)
|
||||
}
|
||||
|
||||
function compareCommits(token, repoFullName, from, to) {
|
||||
return fetchGitHubJson(`${GITHUB_API_BASE}/repos/${repoFullName}/compare/${from}...${to}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
}, 'compareCommits').then(r => { r.files || [] })
|
||||
}
|
||||
|
||||
// zip
|
||||
function getRepoZipball(token, repoFullName, sha) {
|
||||
return fetchGitHubStream(`${GITHUB_API_BASE}/repos/${repoFullName}/zipball/${sha}`, {
|
||||
headers: buildHeaders(token),
|
||||
signal: AbortSignal.timeout(REQUEST_LONG_TIMEOUT_MS),
|
||||
}, 'getRepoZipball')
|
||||
}
|
||||
|
||||
export default {
|
||||
maxConcurrency,
|
||||
getOAuth2Url,
|
||||
exchangeCodeForToken,
|
||||
revokeToken,
|
||||
getUser,
|
||||
getUserAndOrgs,
|
||||
getPushPermission,
|
||||
createRepo,
|
||||
listUserRepos,
|
||||
uploadBlob,
|
||||
getBlobStream,
|
||||
createTree,
|
||||
createCommit,
|
||||
getCommitTree,
|
||||
listBlobsAtCommit,
|
||||
listNewCommitsWithStatus,
|
||||
getBranchHead,
|
||||
createBranch,
|
||||
updateBranch,
|
||||
deleteBranch,
|
||||
mergeBranch,
|
||||
compareCommits,
|
||||
getRepoZipball,
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs'
|
||||
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
|
||||
import Csrf from '../../../../app/src/infrastructure/Csrf.mjs'
|
||||
import GitHubSyncHandler from './GitHubSyncHandler.mjs'
|
||||
import TokenManager from './TokenManager.mjs'
|
||||
import api from './GitHubApiClient.mjs'
|
||||
import { doGitMerge } from './GitMerge.mjs'
|
||||
import { InvalidTokenError, GitNotLinkedError, AlreadyExistsError } from './GitSyncErrors.mjs'
|
||||
|
||||
async function getConnectionStatus(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
|
||||
try {
|
||||
const isConnected = await GitHubSyncHandler.getGitConnState(userId)
|
||||
res.json(isConnected)
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info }, "failed to check user connection")
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjectState(req, res) {
|
||||
const { project_id: projectId } = req.params
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
|
||||
try {
|
||||
const pss = await GitHubSyncHandler.getProjectState(userId, projectId)
|
||||
return res.json(pss)
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info }, "failed get project sync state")
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserAndOrgs(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const userAndOrgs = await GitHubSyncHandler.getUserAndOrgs(userId)
|
||||
res.json(userAndOrgs)
|
||||
}
|
||||
async function listUserRepos(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
|
||||
try {
|
||||
const repos = await GitHubSyncHandler.listUserRepos(userId)
|
||||
res.json({ repos })
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidTokenError) {
|
||||
return res.json(null)
|
||||
}
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ userId, info }, 'Failed to get repositories list')
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function getMergeOverview(req, res) {
|
||||
const { project_id: projectId } = req.params
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
try {
|
||||
const commitsAndStatus = await GitHubSyncHandler.getMergeOverview(userId, projectId)
|
||||
res.json(commitsAndStatus)
|
||||
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, projectId }, 'Error listing commits since last sync')
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Import a GitHub repository as a new project
|
||||
async function importRepo(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { name, fullName, defaultBranchName } = req.body
|
||||
|
||||
try {
|
||||
const projectId = await GitHubSyncHandler.importRepo(userId, name, fullName, defaultBranchName)
|
||||
res.json({ projectId })
|
||||
} catch (error) {
|
||||
logger.error({ error, userId }, 'Failed to import git repository from server')
|
||||
res.status(error.status || 500).json({ message: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect user to Git server OAuth2 authorization URL
|
||||
async function oauth2(req, res) {
|
||||
const oauth2Url = api.getOAuth2Url()
|
||||
oauth2Url.searchParams.append('state', req.csrfToken())
|
||||
res.redirect(oauth2Url.toString())
|
||||
}
|
||||
|
||||
// callback
|
||||
async function oauth2Callback(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { code, state } = req.query
|
||||
|
||||
try {
|
||||
await Csrf.promises.validateToken(state, req.session)
|
||||
} catch {
|
||||
HttpErrorHandler.forbidden(req, res, 'Invalid CSRF token')
|
||||
return
|
||||
}
|
||||
|
||||
let token
|
||||
try {
|
||||
token = await api.exchangeCodeForToken(code)
|
||||
if (!token) {
|
||||
HttpErrorHandler.badRequest(req, res, 'Failed to obtain access token from Git server')
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
const info = OError.getFullInfo(err)
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, userId }, 'Failed to obtain access token from Git server')
|
||||
HttpErrorHandler.badRequest(req, res, error.message || 'Bad request')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await TokenManager.saveUserToken(userId, token)
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, userId }, 'Error saving user token')
|
||||
HttpErrorHandler.handleErrorByStatusCode(req, res, err, errStatus)
|
||||
return
|
||||
}
|
||||
|
||||
// Save success message in session to display on redirect
|
||||
req.session.projectSyncSuccessMessage =
|
||||
req.i18n.translate('github_successfully_linked_description')
|
||||
res.redirect('/user/settings?oauth-complete=github#project-sync')
|
||||
}
|
||||
|
||||
// Unlink user's Git server account
|
||||
async function unlink(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
try {
|
||||
await TokenManager.removeUserToken(userId)
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, projectId }, 'Error removing user credentials')
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// Export project to Git server
|
||||
// Expected req.body:
|
||||
// name (string): repository name
|
||||
// description (string, optional): repository description
|
||||
// isPublic (boolean, optional): if true, the repository is public
|
||||
// org (string, optional): if provided, repository will be created under this organization
|
||||
|
||||
async function exportProject(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { project_id: projectId } = req.params
|
||||
|
||||
try {
|
||||
await GitHubSyncHandler.exportProject(userId, projectId, req.body)
|
||||
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, projectId }, 'Error exporting project')
|
||||
let key = 'github_validation_check'
|
||||
if (err instanceof AlreadyExistsError) key = 'github_validation_name_exists'
|
||||
else if (errStatus === 401 || errStatus === 403) key = 'github_validation_check_auth'
|
||||
return res.status(errStatus).json({ key, message: err.message })
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async function gitMerge(req, res) {
|
||||
const { project_id: projectId } = req.params
|
||||
try {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const commitMessage = req.body?.message || 'Updates from Overleaf'
|
||||
const claimConflictIsResolved = req.body.claimConflictIsResolved
|
||||
|
||||
const mergeResult = await doGitMerge(userId, projectId, commitMessage, claimConflictIsResolved)
|
||||
|
||||
res.json(mergeResult)
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, projectId }, 'Error syncing project to GitHub')
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// Unlink user's Git server account
|
||||
async function unlinkRepo(req, res) {
|
||||
const { project_id: projectId } = req.params
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
try {
|
||||
const ownerEmail = await GitHubSyncHandler.unlinkRepo(userId, projectId)
|
||||
if (ownerEmail) return res.status(403).json({ ownerEmail })
|
||||
} catch (err) {
|
||||
const info = OError.getFullInfo(err)
|
||||
const errStatus = info?.status || 500
|
||||
logger.error(OError.getFullStack(err))
|
||||
logger.error({ info, projectId }, 'Error unlinking repo from project')
|
||||
return res.status(errStatus).json({ message: err.message })
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
export default {
|
||||
getConnectionStatus: expressify(getConnectionStatus),
|
||||
getProjectState: expressify(getProjectState),
|
||||
oauth2: expressify(oauth2),
|
||||
unlink: expressify(unlink),
|
||||
getUserAndOrgs: expressify(getUserAndOrgs),
|
||||
oauth2Callback: expressify(oauth2Callback),
|
||||
listUserRepos: expressify(listUserRepos),
|
||||
importRepo: expressify(importRepo),
|
||||
exportProject: expressify(exportProject),
|
||||
getMergeOverview: expressify(getMergeOverview),
|
||||
gitMerge: expressify(gitMerge),
|
||||
unlinkRepo: expressify(unlinkRepo),
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import Path from 'path'
|
||||
import fs from 'fs'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import crypto from 'crypto'
|
||||
import pLimit from 'p-limit'
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs'
|
||||
import ProjectUploadManager from '../../../../app/src/Features/Uploads/ProjectUploadManager.mjs'
|
||||
import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs'
|
||||
import DocumentUpdaterHandler from '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
|
||||
import api from './GitHubApiClient.mjs'
|
||||
import SyncStateManager from './SyncStateManager.mjs'
|
||||
import HistoryManager from './HistoryManager.mjs'
|
||||
import TokenManager from './TokenManager.mjs'
|
||||
import { InvalidTokenError, NotFoundError } from './GitSyncErrors.mjs'
|
||||
|
||||
async function getGitConnState(userId) {
|
||||
try {
|
||||
const token = await TokenManager.getUserToken(userId)
|
||||
await api.getUser(token)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidTokenError) {
|
||||
logger.debug( { err, userId }, 'token invalid, treating as not connected')
|
||||
return false
|
||||
}
|
||||
throw OError.tag(err, 'failed to validate token', { userId })
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjectState(userId, projectId) {
|
||||
let pss = null
|
||||
const projection = { _id: 0, mergeStatus: 1, repoFullName: 1, unmergedBranchName: 1 }
|
||||
pss = await SyncStateManager.getProjectState(projectId, projection)
|
||||
if (!pss) {
|
||||
pss = { mergeStatus: 'need-export' }
|
||||
try {
|
||||
const { owner_ref } = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1 })
|
||||
if (owner_ref.toString() !== userId) {
|
||||
pss.ownerEmail = await UserGetter.promises.getUserEmail(owner_ref)
|
||||
}
|
||||
} catch (err) {
|
||||
pss.ownerEmail = 'nobody@nowhere'
|
||||
logger.error({ err }, "failed get user email")
|
||||
}
|
||||
return pss
|
||||
}
|
||||
let canPush
|
||||
try {
|
||||
const token = await TokenManager.getUserToken(userId)
|
||||
canPush = await api.getPushPermission(token, pss.repoFullName)
|
||||
} catch (err) {
|
||||
if ((err instanceof NotFoundError) ||
|
||||
(err instanceof PermissionDeniedError)
|
||||
) canPush = false
|
||||
else throw err
|
||||
}
|
||||
if (!canPush) {
|
||||
pss.mergeStatus = 'need-permission'
|
||||
// send owner's email to collaborator
|
||||
// send nothing to the owner
|
||||
try {
|
||||
const { owner_ref } = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1 })
|
||||
if (owner_ref.toString() !== userId) {
|
||||
pss.ownerEmail = await UserGetter.promises.getUserEmail(owner_ref)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, userId }, "failed get user email")
|
||||
pss.ownerEmail = 'nobody@nowhere'
|
||||
}
|
||||
}
|
||||
return pss
|
||||
}
|
||||
|
||||
async function unlinkRepo(userId, projectId) {
|
||||
const { owner_ref } = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1 })
|
||||
if (owner_ref.toString() !== userId) {
|
||||
let ownerEmail
|
||||
try {
|
||||
ownerEmail = await UserGetter.promises.getUserEmail(owner_ref)
|
||||
} catch (err) {
|
||||
logger.error({ err, userId }, "failed get user email")
|
||||
ownerEmail = 'nobody@nowhere'
|
||||
}
|
||||
return ownerEmail
|
||||
}
|
||||
await SyncStateManager.removeProjectState(projectId)
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
async function listUserRepos(userId) {
|
||||
const token = await TokenManager.getUserToken(userId)
|
||||
return await api.listUserRepos(token)
|
||||
}
|
||||
|
||||
async function getUserAndOrgs(userId) {
|
||||
const token = await TokenManager.getUserToken(userId)
|
||||
return await api.getUserAndOrgs(token)
|
||||
}
|
||||
|
||||
async function getMergeOverview(userId, projectId) {
|
||||
const projectSyncState = await SyncStateManager.getProjectState(projectId)
|
||||
if (!projectSyncState) throw new GitNotLinkedError(projectId)
|
||||
const { repoFullName, defaultBranchName, lastSyncCommit, lastSyncVersion, mergeStatus } = projectSyncState
|
||||
|
||||
if (mergeStatus === 'conflict') return null
|
||||
|
||||
const token = await TokenManager.getUserToken(userId)
|
||||
const currentVersion = await HistoryManager.latestVersion(projectId.toString())
|
||||
const isProjectUpdated = currentVersion !== lastSyncVersion
|
||||
try {
|
||||
const commitsAndStatus = await api.listNewCommitsWithStatus(token, repoFullName, defaultBranchName, lastSyncCommit)
|
||||
if(commitsAndStatus.diverged) {
|
||||
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'diverged' } )
|
||||
}
|
||||
return { ...commitsAndStatus, isProjectUpdated }
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
// what is the reason of NotFoundError? is it a missing commit or not?
|
||||
let canPush
|
||||
try {
|
||||
canPush = await api.getPushPermission(token, repoFullName)
|
||||
} catch (e) {
|
||||
logger.error({ error: e }, "failed to check push permission")
|
||||
canPush = false
|
||||
}
|
||||
if (!canPush) throw err
|
||||
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'diverged' } )
|
||||
return { commits: [], diverged: true, isProjectUpdated }
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importRepo(userId, projectName, repoFullName, defaultBranchName) {
|
||||
const token = await TokenManager.getUserToken(userId)
|
||||
const defaultBranchHead = await api.getBranchHead(token, repoFullName, defaultBranchName)
|
||||
const fsPath = Path.join(Settings.path.dumpFolder, `github_import_${crypto.randomUUID()}`)
|
||||
|
||||
let project_id
|
||||
|
||||
try {
|
||||
const stream = await api.getRepoZipball(token, repoFullName, defaultBranchHead)
|
||||
await pipeline(stream, fs.createWriteStream(fsPath))
|
||||
|
||||
const { project } = await ProjectUploadManager.promises.createProjectFromZipArchiveWithName(
|
||||
userId,
|
||||
projectName,
|
||||
fsPath
|
||||
)
|
||||
|
||||
project_id = project?._id
|
||||
} catch (err) {
|
||||
throw OError.tag(
|
||||
err,
|
||||
'failed importing git repo',
|
||||
{ userId, projectName, repoFullName, defaultBranchName, fsPath }
|
||||
)
|
||||
} finally {
|
||||
fs.promises.rm(fsPath, { force: true }).catch(() => {})
|
||||
}
|
||||
|
||||
try {
|
||||
const projectVersion = await HistoryManager.latestVersion(project_id.toString())
|
||||
await SyncStateManager.createProjectState(project_id, {
|
||||
repoFullName,
|
||||
mergeStatus: 'clean',
|
||||
lastSyncCommit: defaultBranchHead,
|
||||
defaultBranchName,
|
||||
lastSyncVersion: projectVersion
|
||||
})
|
||||
} catch (err) {
|
||||
// don't throw up
|
||||
logger.error(
|
||||
{ err, userId, projectId: project_id.toString(), repoFullName },
|
||||
'Failed to create state of imported repo'
|
||||
)
|
||||
}
|
||||
|
||||
return project_id
|
||||
}
|
||||
|
||||
async function exportProject(userId, projectId, repoOptions) {
|
||||
const isLinked = await SyncStateManager.getProjectState(projectId)
|
||||
if (isLinked) {
|
||||
throw new OError('Project is already linked to Git server', { projectId })
|
||||
}
|
||||
|
||||
const token = await TokenManager.getUserToken(userId)
|
||||
|
||||
const { full_name: repoFullName, default_branch: defaultBranchName } =
|
||||
await api.createRepo(token, repoOptions)
|
||||
|
||||
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
|
||||
|
||||
const currentVersion = await HistoryManager.latestVersion(projectId)
|
||||
const currentPaths = await HistoryManager.getPathsAtVersion(projectId, currentVersion)
|
||||
|
||||
const limit = pLimit(api.maxConcurrency)
|
||||
const blobData = await Promise.all(
|
||||
currentPaths.paths.map((path) =>
|
||||
limit(async () => {
|
||||
const buffer = await HistoryManager.getProjectFileBuffer(projectId, currentVersion, path)
|
||||
const sha = await api.uploadBlob(token, repoFullName, buffer)
|
||||
return { path, sha }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const tree = await api.createTree(token, repoFullName, blobData)
|
||||
const initialCommit = await api.createCommit(token, repoFullName, {
|
||||
tree,
|
||||
message: 'Initial Overleaf import',
|
||||
})
|
||||
|
||||
const force = true
|
||||
await api.updateBranch(token, repoFullName, defaultBranchName, initialCommit, force)
|
||||
|
||||
return SyncStateManager.createProjectState(projectId, {
|
||||
mergeStatus: 'clean',
|
||||
defaultBranchName,
|
||||
lastSyncCommit: initialCommit,
|
||||
lastSyncVersion: currentVersion,
|
||||
repoFullName,
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
exportProject,
|
||||
getProjectState,
|
||||
unlinkRepo,
|
||||
importRepo,
|
||||
getGitConnState,
|
||||
listUserRepos,
|
||||
getUserAndOrgs,
|
||||
getMergeOverview,
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import logger from '@overleaf/logger'
|
||||
|
||||
import GitHubSyncController from './GitHubSyncController.mjs'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
|
||||
import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
logger.debug({}, 'Init github-sync router')
|
||||
// start the GitHub OAuth flow by redirecting to GitHub authorization page
|
||||
webRouter.get(
|
||||
'/user/github-sync/oauth2',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitHubSyncController.oauth2
|
||||
)
|
||||
|
||||
// callback for GitHub OAuth flow
|
||||
webRouter.get(
|
||||
'/user/github-sync/oauth2/callback',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitHubSyncController.oauth2Callback
|
||||
)
|
||||
|
||||
// unlink GitHub account
|
||||
webRouter.post(
|
||||
'/user/github-sync/unlink',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitHubSyncController.unlink
|
||||
)
|
||||
|
||||
// get user git connection status
|
||||
webRouter.get(
|
||||
'/user/github-sync/status',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitHubSyncController.getConnectionStatus
|
||||
)
|
||||
|
||||
// get git user name and user's organizations
|
||||
webRouter.get(
|
||||
'/user/github-sync/orgs',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitHubSyncController.getUserAndOrgs
|
||||
)
|
||||
|
||||
// list user's repos
|
||||
webRouter.get(
|
||||
'/user/github-sync/repos',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitHubSyncController.listUserRepos
|
||||
)
|
||||
|
||||
// create a new project from git server repo
|
||||
webRouter.post(
|
||||
'/project/new/github-sync',
|
||||
AuthenticationController.requireLogin(),
|
||||
GitHubSyncController.importRepo
|
||||
)
|
||||
|
||||
// export project to git server
|
||||
webRouter.post(
|
||||
'/project/:project_id/github-sync/export',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
GitHubSyncController.exportProject
|
||||
)
|
||||
|
||||
// get project sync state
|
||||
webRouter.get(
|
||||
'/project/:project_id/github-sync/state',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
GitHubSyncController.getProjectState
|
||||
)
|
||||
|
||||
// get overview of the coming merge:
|
||||
// merge state (clean or diverged), unmerged commits, is OL version changed?
|
||||
webRouter.get(
|
||||
'/project/:project_id/github-sync/merge/overview',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
GitHubSyncController.getMergeOverview
|
||||
)
|
||||
|
||||
// merge OL project with Git server repo
|
||||
webRouter.post(
|
||||
'/project/:project_id/github-sync/merge',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
GitHubSyncController.gitMerge
|
||||
)
|
||||
|
||||
// unlink Git repo from Git server
|
||||
webRouter.delete(
|
||||
'/project/:project_id/github-sync',
|
||||
AuthenticationController.requireLogin(),
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
GitHubSyncController.unlinkRepo
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
import { normalize } from 'path/posix'
|
||||
import pLimit from 'p-limit'
|
||||
import { createHash } from 'crypto'
|
||||
import logger from '@overleaf/logger'
|
||||
import SyncStateManager from './SyncStateManager.mjs'
|
||||
import api from './GitHubApiClient.mjs'
|
||||
import HistoryManager from './HistoryManager.mjs'
|
||||
import { GitConflictError } from './GitSyncErrors.mjs'
|
||||
import TokenManager from './TokenManager.mjs'
|
||||
import { ObjectId } from '../../../../app/src/infrastructure/mongodb.mjs'
|
||||
import LockManager from '../../../../app/src/infrastructure/LockManager.mjs'
|
||||
import ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs'
|
||||
import UpdateMerger from '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs'
|
||||
import DocumentUpdaterHandler from '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
|
||||
|
||||
const SyncLockManager = LockManager.withTimeout(300) // 5 min
|
||||
|
||||
export async function doGitMerge(userId, projectId, message, claimConflictIsResolved) {
|
||||
return await SyncLockManager.promises.runWithLock(
|
||||
'gitSync',
|
||||
projectId,
|
||||
() => doGitMergeWithoutLock(userId, projectId, message, claimConflictIsResolved)
|
||||
)
|
||||
}
|
||||
|
||||
async function doGitMergeWithoutLock(userId, projectId, message, claimConflictIsResolved) {
|
||||
const projectSyncState = await SyncStateManager.getProjectState(projectId)
|
||||
if (!projectSyncState) throw new GitNotLinkedError(projectId)
|
||||
const {
|
||||
repoFullName,
|
||||
defaultBranchName,
|
||||
mergeStatus,
|
||||
unmergedBranchName
|
||||
} = projectSyncState
|
||||
|
||||
// do nothing if conflict is not yet resolved and 'Sync' button is pushed
|
||||
// do nothing if conflict is already resolved and 'I have manually merged' button is pushed
|
||||
if (
|
||||
!claimConflictIsResolved && mergeStatus === 'conflict' ||
|
||||
claimConflictIsResolved && mergeStatus !== 'conflict'
|
||||
) return { mergeStatus, repoFullName, unmergedBranchName }
|
||||
|
||||
const token = await TokenManager.getUserToken(userId)
|
||||
|
||||
await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
|
||||
|
||||
const currentVersion = await HistoryManager.latestVersion(projectId)
|
||||
const defaultBranchHead = await api.getBranchHead(token, repoFullName, defaultBranchName)
|
||||
|
||||
let newUnmergedBranchName
|
||||
|
||||
switch (mergeStatus) {
|
||||
|
||||
case 'clean':
|
||||
newUnmergedBranchName = await resolveCleanSyncState(
|
||||
token,
|
||||
userId,
|
||||
projectSyncState,
|
||||
currentVersion,
|
||||
defaultBranchHead,
|
||||
message,
|
||||
)
|
||||
break
|
||||
|
||||
case 'conflict':
|
||||
newUnmergedBranchName = await resolveConflictSyncState(
|
||||
token,
|
||||
userId,
|
||||
projectSyncState,
|
||||
currentVersion,
|
||||
defaultBranchHead
|
||||
)
|
||||
break
|
||||
|
||||
case 'diverged':
|
||||
newUnmergedBranchName = await resolveDetachedSyncState(
|
||||
token,
|
||||
userId,
|
||||
projectSyncState,
|
||||
currentVersion,
|
||||
defaultBranchHead,
|
||||
message,
|
||||
)
|
||||
break
|
||||
|
||||
default:
|
||||
throw new Error('Bad mergeStatus')
|
||||
}
|
||||
|
||||
const newMergeStatus = newUnmergedBranchName ? 'conflict' : 'clean'
|
||||
return { mergeStatus: newMergeStatus, repoFullName, unmergedBranchName: newUnmergedBranchName }
|
||||
}
|
||||
|
||||
// ----------- clean flow
|
||||
async function resolveCleanSyncState(
|
||||
token,
|
||||
userId,
|
||||
projectSyncState,
|
||||
currentVersion,
|
||||
defaultBranchHead,
|
||||
message,
|
||||
) {
|
||||
const { projectId, repoFullName, defaultBranchName } = projectSyncState
|
||||
|
||||
// No changes in OL
|
||||
if (currentVersion === projectSyncState.lastSyncVersion) {
|
||||
// No changes in GH too: everything is up to date
|
||||
if (projectSyncState.lastSyncCommit === defaultBranchHead) return null
|
||||
|
||||
// Need to apply only GH changes
|
||||
const lastSyncCommit = defaultBranchHead
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
|
||||
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
}
|
||||
|
||||
// Possible changes in OL
|
||||
const olBranchHead =
|
||||
await exportChangesToGit({
|
||||
token,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncVersion: projectSyncState.lastSyncVersion,
|
||||
currentVersion,
|
||||
message,
|
||||
baseCommit: projectSyncState.lastSyncCommit,
|
||||
})
|
||||
|
||||
if (!olBranchHead) {
|
||||
if ( defaultBranchHead === projectSyncState.lastSyncCommit) {
|
||||
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion: currentVersion })
|
||||
return null
|
||||
}
|
||||
|
||||
const lastSyncCommit = defaultBranchHead
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
}
|
||||
|
||||
if (defaultBranchHead === projectSyncState.lastSyncCommit) {
|
||||
// Trying to FF
|
||||
try {
|
||||
await api.updateBranch(token, repoFullName, defaultBranchName, olBranchHead)
|
||||
|
||||
const lastSyncCommit = olBranchHead
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
|
||||
} catch (err) {
|
||||
if (!(err instanceof GitConflictError)) { throw err }
|
||||
}
|
||||
}
|
||||
|
||||
// FF failed, fallback to merge
|
||||
const { mergeCommit, tempBranchName, conflict } = await mergeWithTempBranch(
|
||||
token,
|
||||
repoFullName,
|
||||
defaultBranchName,
|
||||
olBranchHead
|
||||
)
|
||||
|
||||
if (conflict) {
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'conflict',
|
||||
lastSyncCommit: defaultBranchHead,
|
||||
unmergedBranchName: tempBranchName,
|
||||
unmergedBranchHead: olBranchHead,
|
||||
conflictVersion: currentVersion,
|
||||
})
|
||||
return tempBranchName
|
||||
} else {
|
||||
const lastSyncCommit = mergeCommit
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, { lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ----------- conflict flow
|
||||
async function resolveConflictSyncState(
|
||||
token,
|
||||
userId,
|
||||
projectSyncState,
|
||||
currentVersion,
|
||||
defaultBranchHead
|
||||
) {
|
||||
const { projectId, repoFullName, defaultBranchName, conflictVersion } = projectSyncState
|
||||
|
||||
// No OL changes after manual conflict resolving: pull GH changes only
|
||||
if (currentVersion === conflictVersion) {
|
||||
const lastSyncCommit = defaultBranchHead
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'clean',
|
||||
lastSyncVersion,
|
||||
lastSyncCommit,
|
||||
conflictVersion: null,
|
||||
unmergedBranchName: null,
|
||||
unmergedBranchHead: null
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// OL was changed after manual conflict resolution: need to merge
|
||||
const prevOlBranchHead = projectSyncState.unmergedBranchHead
|
||||
|
||||
const newOlBranchHead = await exportChangesToGit({
|
||||
token,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncVersion: conflictVersion,
|
||||
currentVersion,
|
||||
message: '[Updates in Overleaf during conflict resolution]',
|
||||
baseCommit: prevOlBranchHead,
|
||||
})
|
||||
|
||||
// Are there really were changes? (e.g.: file created, then removedi: no changes)
|
||||
if (!newOlBranchHead) {
|
||||
// nothing to push to GH
|
||||
const lastSyncCommit = defaultBranchHead
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'clean',
|
||||
lastSyncVersion,
|
||||
lastSyncCommit,
|
||||
conflictVersion: null,
|
||||
unmergedBranchName: null,
|
||||
unmergedBranchHead: null
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// merge
|
||||
const { mergeCommit, tempBranchName, conflict } = await mergeWithTempBranch(
|
||||
token,
|
||||
repoFullName,
|
||||
defaultBranchName,
|
||||
newOlBranchHead,
|
||||
)
|
||||
|
||||
if (conflict) {
|
||||
// conflict must be resolved manually
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'conflict',
|
||||
lastSyncCommit: defaultBranchHead,
|
||||
unmergedBranchName: tempBranchName,
|
||||
unmergedBranchHead: newOlBranchHead,
|
||||
conflictVersion: currentVersion,
|
||||
})
|
||||
return tempBranchName
|
||||
} else {
|
||||
// merge succeeded
|
||||
const lastSyncCommit = mergeCommit
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'clean',
|
||||
lastSyncVersion,
|
||||
lastSyncCommit,
|
||||
conflictVersion: null,
|
||||
unmergedBranchName: null,
|
||||
unmergedBranchHead: null
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ----------- detached flow
|
||||
// GH repo was force pushed, lastSybc commit possibly anavailable
|
||||
async function resolveDetachedSyncState(
|
||||
token,
|
||||
userId,
|
||||
projectSyncState,
|
||||
currentVersion,
|
||||
defaultBranchHead,
|
||||
message,
|
||||
) {
|
||||
const { projectId, repoFullName, defaultBranchName } = projectSyncState
|
||||
|
||||
// No changes in OL: re-anchor
|
||||
if (currentVersion === projectSyncState.lastSyncVersion) {
|
||||
const lastSyncCommit = defaultBranchHead
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'clean', lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
}
|
||||
|
||||
// Need to compare lastSync OL version (base), current OL version (local) and GH version (remote)
|
||||
const baseSnapshot = await HistoryManager.getProjectSnapshot(projectId, projectSyncState.lastSyncVersion)
|
||||
const localSnapshot = await HistoryManager.getProjectSnapshot(projectId, currentVersion)
|
||||
const remoteBlobMap = await getGitBlobMap(token, repoFullName, defaultBranchHead)
|
||||
|
||||
const {
|
||||
cleanLocalEntries,
|
||||
conflictRemoteEntries,
|
||||
conflictLocalEntries,
|
||||
conflictBaseEntries,
|
||||
hasConflicts,
|
||||
hasChanges
|
||||
} = await buildDetachedSyncPlan({
|
||||
token,
|
||||
repoFullName,
|
||||
projectId,
|
||||
currentVersion,
|
||||
baseSnapshot,
|
||||
localSnapshot,
|
||||
remoteBlobMap,
|
||||
})
|
||||
|
||||
// were any changes introduced in GH?
|
||||
if (!hasChanges) {
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'clean',
|
||||
lastSyncVersion: currentVersion,
|
||||
lastSyncCommit: defaultBranchHead
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (!hasConflicts) {
|
||||
let lastSyncCommit = defaultBranchHead
|
||||
if (cleanLocalEntries.length !== 0) {
|
||||
lastSyncCommit = await createCommitFromEntries({
|
||||
token,
|
||||
repoFullName,
|
||||
parentCommit: defaultBranchHead,
|
||||
entries: cleanLocalEntries,
|
||||
message,
|
||||
})
|
||||
await api.updateBranch(token, repoFullName, defaultBranchName, lastSyncCommit)
|
||||
}
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'clean', lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve potential conflict.
|
||||
// Build synthetic merge topology:
|
||||
//
|
||||
// GH
|
||||
// /
|
||||
// H --- B ---
|
||||
// \
|
||||
// OL
|
||||
//
|
||||
// merge(GH <- OL)
|
||||
//
|
||||
// B: conflicted files are restored to lastSyncVersion (merge base)
|
||||
// GH: in conflicted files remote changes after lastSyncVersion are replayed
|
||||
// OL: all changes done in Overleaf after lastSyncVersion are applied
|
||||
|
||||
// Blobs referenced in conflictBaseEntries in theory could be lost, but for GitHub,
|
||||
// GitLab and Gitea, blobs from historical commits are normally retained for a
|
||||
// very long time so the practical probability of failure is likely close to zero.
|
||||
// TODO: anyway, if error is thrown by api.createTree, upload all blobs
|
||||
// referenced in conflictBaseEntries and try again
|
||||
|
||||
// H --- B
|
||||
const baseCommit = await createCommitFromEntries({
|
||||
token,
|
||||
repoFullName,
|
||||
parentCommit: defaultBranchHead,
|
||||
entries: conflictBaseEntries,
|
||||
message: '[Overleaf GitSync conflict resolution: restore last sync]',
|
||||
})
|
||||
|
||||
// H --- B --- GH
|
||||
const remoteChangesCommit = await createCommitFromEntries({
|
||||
token,
|
||||
repoFullName,
|
||||
parentCommit: baseCommit,
|
||||
entries: conflictRemoteEntries,
|
||||
message: '[Overleaf GitSync conflict resolution: replay repo changes]',
|
||||
})
|
||||
|
||||
// H --- B --- OL
|
||||
const olChangesCommit = await createCommitFromEntries({
|
||||
token,
|
||||
repoFullName,
|
||||
parentCommit: baseCommit,
|
||||
entries: [...cleanLocalEntries, ...conflictLocalEntries],
|
||||
message,
|
||||
})
|
||||
|
||||
// default branch: --- H --- B --- GH
|
||||
await api.updateBranch(
|
||||
token,
|
||||
repoFullName,
|
||||
defaultBranchName,
|
||||
remoteChangesCommit
|
||||
)
|
||||
|
||||
// temp branch: --- H --- B --- OL
|
||||
// merge: default <- temp (GH <- OL)
|
||||
const { mergeCommit, tempBranchName, conflict } = await mergeWithTempBranch(
|
||||
token,
|
||||
repoFullName,
|
||||
defaultBranchName,
|
||||
olChangesCommit
|
||||
)
|
||||
|
||||
// auto-merge succeeded, conflicts in editable files were resolved?
|
||||
if (!conflict) {
|
||||
const lastSyncCommit = mergeCommit
|
||||
const lastSyncVersion = await applyGitSnapshotToProject({
|
||||
token,
|
||||
userId,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncCommit,
|
||||
})
|
||||
await SyncStateManager.updateProjectState(projectId, { mergeStatus: 'clean', lastSyncVersion, lastSyncCommit })
|
||||
return null
|
||||
}
|
||||
|
||||
// merge failed: must manually resolve conflicts
|
||||
await SyncStateManager.updateProjectState(projectId, {
|
||||
mergeStatus: 'conflict',
|
||||
lastSyncCommit: remoteChangesCommit,
|
||||
unmergedBranchName: tempBranchName,
|
||||
unmergedBranchHead: olChangesCommit,
|
||||
conflictVersion: currentVersion,
|
||||
})
|
||||
return tempBranchName
|
||||
}
|
||||
|
||||
// ----------- Helpers
|
||||
async function applyGitSnapshotToProject({ token, userId, projectId, repoFullName, lastSyncCommit }) {
|
||||
|
||||
const invalidFiles = []
|
||||
const updates = []
|
||||
const deletions = []
|
||||
|
||||
const currentVersion = await HistoryManager.latestVersion(projectId)
|
||||
const historySnapshot = await HistoryManager.getProjectSnapshot(projectId, currentVersion)
|
||||
const blobMap = await getGitBlobMap(token, repoFullName, lastSyncCommit)
|
||||
const blobPaths = new Set()
|
||||
|
||||
for (const [path, sha] of Object.entries(blobMap)) {
|
||||
const invalid = validateFileName(path)
|
||||
|
||||
if (invalid) {
|
||||
invalidFiles.push(invalid.name)
|
||||
continue
|
||||
}
|
||||
|
||||
blobPaths.add(path)
|
||||
|
||||
const hstSha = historySnapshot[path]?.data?.hash
|
||||
// File at path is not changed, do not touch it
|
||||
if (hstSha === sha) continue
|
||||
|
||||
updates.push(path)
|
||||
}
|
||||
|
||||
for (const path of Object.keys(historySnapshot)) {
|
||||
if (blobPaths.has(path)) continue
|
||||
deletions.push(path)
|
||||
}
|
||||
|
||||
// TODO: do not silently ignore invalidFiles, inform user about them
|
||||
if (invalidFiles.length > 0) {
|
||||
logger.warn({ projectId, invalidFiles }, 'Invalid file paths detected at Git server')
|
||||
}
|
||||
const limit = pLimit(api.maxConcurrency)
|
||||
await Promise.all(
|
||||
updates.map(path => limit(async () => {
|
||||
const stream = await api.getBlobStream(token, repoFullName, lastSyncCommit, path)
|
||||
return UpdateMerger.promises.mergeUpdate(userId, projectId, '/' + path, stream, 'github')
|
||||
}))
|
||||
)
|
||||
const localLimit = pLimit(5)
|
||||
await Promise.all(
|
||||
deletions.map(path => localLimit(() => {
|
||||
return UpdateMerger.promises.deleteUpdate(userId, projectId, '/' + path, 'github')
|
||||
}))
|
||||
)
|
||||
|
||||
return await HistoryManager.latestVersion(projectId)
|
||||
}
|
||||
|
||||
async function buildDetachedSyncPlan({
|
||||
token,
|
||||
repoFullName,
|
||||
projectId,
|
||||
currentVersion,
|
||||
baseSnapshot,
|
||||
localSnapshot,
|
||||
remoteBlobMap
|
||||
}) {
|
||||
const cleanLocalEntries = []
|
||||
const conflictRemoteEntries = []
|
||||
const conflictLocalEntries = []
|
||||
const conflictBaseEntries = []
|
||||
|
||||
let hasConflicts = false
|
||||
let hasChanges = false
|
||||
|
||||
const allPaths = new Set([
|
||||
...Object.keys(baseSnapshot || {}),
|
||||
...Object.keys(localSnapshot || {}),
|
||||
...Object.keys(remoteBlobMap || {}),
|
||||
])
|
||||
|
||||
for (const path of allPaths) {
|
||||
const baseHash = getEntryHash(baseSnapshot[path])
|
||||
const localHash = getEntryHash(localSnapshot[path])
|
||||
const remoteHash = remoteBlobMap[path] || null
|
||||
|
||||
// currentVesion of the file is not known to git server, uploading blob
|
||||
if (localHash && localHash !== baseHash && localHash !== remoteHash) {
|
||||
let buffer
|
||||
if (localSnapshot[path].data?.content) {
|
||||
buffer = Buffer.from(localSnapshot[path].data.content, 'utf8').toString('base64')
|
||||
} else {
|
||||
buffer = await HistoryManager.getProjectFileBuffer(projectId, currentVersion, path)
|
||||
}
|
||||
const sha = await api.uploadBlob(token, repoFullName, buffer)
|
||||
// assert sha !== localHash
|
||||
}
|
||||
|
||||
// OL == GH, do nothing
|
||||
if (localHash === remoteHash) continue
|
||||
|
||||
if (baseHash === remoteHash) {
|
||||
// changed in OL
|
||||
hasChanges = true
|
||||
cleanLocalEntries.push({ path, sha: localHash })
|
||||
continue
|
||||
}
|
||||
|
||||
if (baseHash === localHash) {
|
||||
// changed in GH
|
||||
hasChanges = true
|
||||
continue
|
||||
}
|
||||
// conflict: changed both in GH and OL
|
||||
hasChanges = true
|
||||
hasConflicts = true
|
||||
|
||||
conflictRemoteEntries.push({ path, sha: remoteHash })
|
||||
conflictLocalEntries.push({ path, sha: localHash})
|
||||
conflictBaseEntries.push({ path, sha: baseHash })
|
||||
}
|
||||
return {
|
||||
cleanLocalEntries,
|
||||
conflictRemoteEntries,
|
||||
conflictLocalEntries,
|
||||
conflictBaseEntries,
|
||||
hasConflicts,
|
||||
hasChanges,
|
||||
}
|
||||
}
|
||||
|
||||
async function createCommitFromEntries({
|
||||
token,
|
||||
repoFullName,
|
||||
parentCommit,
|
||||
entries,
|
||||
message,
|
||||
}) {
|
||||
const baseTree = await api.getCommitTree(token, repoFullName, parentCommit)
|
||||
const newTree = await api.createTree(token, repoFullName, entries, baseTree)
|
||||
return api.createCommit(token, repoFullName, {
|
||||
tree: newTree,
|
||||
parents: [parentCommit],
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
async function mergeWithTempBranch(
|
||||
token,
|
||||
repoFullName,
|
||||
branchName,
|
||||
updateCommit
|
||||
) {
|
||||
const tempBranchName = generateBranchName()
|
||||
|
||||
await api.createBranch(
|
||||
token,
|
||||
repoFullName,
|
||||
tempBranchName,
|
||||
updateCommit
|
||||
)
|
||||
|
||||
try {
|
||||
const mergeCommit =
|
||||
await api.mergeBranch(token, repoFullName, branchName, tempBranchName)
|
||||
|
||||
try {
|
||||
await api.deleteBranch(token, repoFullName, tempBranchName)
|
||||
} catch (err) {
|
||||
logger.warn({ err, repoFullName, tempBranchName }, 'Delete temp branch failed')
|
||||
}
|
||||
|
||||
return { conflict: false, mergeCommit }
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof GitConflictError) {
|
||||
return { conflict: true, tempBranchName }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function exportChangesToGit({
|
||||
token,
|
||||
projectId,
|
||||
repoFullName,
|
||||
lastSyncVersion,
|
||||
currentVersion,
|
||||
message,
|
||||
baseCommit,
|
||||
}) {
|
||||
const diff = await HistoryManager.getProjectFileTreeDiff(
|
||||
projectId,
|
||||
lastSyncVersion,
|
||||
currentVersion
|
||||
)
|
||||
|
||||
const { paths } = await HistoryManager.getPathsAtVersion(projectId, lastSyncVersion)
|
||||
const existedAtLastSync = new Set(paths)
|
||||
|
||||
const deletePaths = new Set()
|
||||
const upsertPaths = new Set()
|
||||
|
||||
for (const item of diff) {
|
||||
const oldPath = item.pathname
|
||||
|
||||
if (item.operation === 'removed') {
|
||||
// it can happen that removed file was created after last sync
|
||||
if (existedAtLastSync.has(oldPath)) deletePaths.add(oldPath)
|
||||
continue
|
||||
}
|
||||
|
||||
if (item.operation === 'renamed') {
|
||||
if (existedAtLastSync.has(oldPath)) deletePaths.add(oldPath)
|
||||
upsertPaths.add(item.newPathname)
|
||||
continue
|
||||
}
|
||||
if (item.operation === 'added' || item.operation === 'edited') {
|
||||
upsertPaths.add(oldPath)
|
||||
}
|
||||
}
|
||||
// if a file is renamed and a new file with the old name is created,
|
||||
// then the name will be in both sets, need to exclude it from deletePaths
|
||||
for (const path of upsertPaths) {
|
||||
if (deletePaths.has(path)) deletePaths.delete(path)
|
||||
}
|
||||
|
||||
// this can happen when a file is created and then removed
|
||||
if (deletePaths.size === 0 && upsertPaths.size === 0) return null
|
||||
|
||||
const limit = pLimit(api.maxConcurrency)
|
||||
const upsertEntries = await Promise.all(
|
||||
[...upsertPaths].map(path =>
|
||||
limit(async () => {
|
||||
const buffer = await HistoryManager.getProjectFileBuffer(projectId, currentVersion, path)
|
||||
const sha = await api.uploadBlob(token, repoFullName, buffer)
|
||||
return { path, sha }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const deleteEntries = [...deletePaths].map(path => ({ path, sha: null }))
|
||||
const entries = [...deleteEntries, ...upsertEntries]
|
||||
|
||||
const newCommit = await createCommitFromEntries({
|
||||
token,
|
||||
repoFullName,
|
||||
parentCommit: baseCommit,
|
||||
entries,
|
||||
message,
|
||||
})
|
||||
|
||||
return newCommit
|
||||
}
|
||||
|
||||
function generateBranchName() {
|
||||
const d = new Date()
|
||||
const pad = n => `${n}`.padStart(2, '0')
|
||||
return `overleaf-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
async function getGitBlobMap(token, repoFullName, commit) {
|
||||
const blobEntries = await api.listBlobsAtCommit(token, repoFullName, commit)
|
||||
return Object.fromEntries(
|
||||
blobEntries.map(({ path, sha }) => [path, sha])
|
||||
)
|
||||
}
|
||||
|
||||
function getEntryHash(entry) {
|
||||
if (!entry?.data) return null
|
||||
return (
|
||||
entry.data.hash ||
|
||||
gitBlobShaFromString(entry.data.content)
|
||||
)
|
||||
}
|
||||
|
||||
function gitBlobShaFromString(str) {
|
||||
if (!str) return null
|
||||
const content = Buffer.from(str, 'utf8')
|
||||
const header = Buffer.from(`blob ${content.length}\0`, 'utf8')
|
||||
const store = Buffer.concat([header, content])
|
||||
|
||||
return createHash('sha1').update(store).digest('hex')
|
||||
}
|
||||
|
||||
function validateFileName(name) {
|
||||
if (
|
||||
typeof name !== 'string' ||
|
||||
name.length === 0 ||
|
||||
name.includes('\0')
|
||||
) return { name }
|
||||
|
||||
const normalized = normalize(name)
|
||||
|
||||
if (
|
||||
normalized !== name ||
|
||||
normalized.startsWith('/') ||
|
||||
normalized.startsWith('../')
|
||||
) return { name }
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import OError from '@overleaf/o-error'
|
||||
|
||||
class GitNotLinkedError extends OError {
|
||||
constructor(projectId) {
|
||||
super('git server is not linked to project', { projectId })
|
||||
}
|
||||
}
|
||||
|
||||
class AlreadyExistsError extends OError {}
|
||||
class GitConflictError extends OError {}
|
||||
class InvalidTokenError extends OError {}
|
||||
class NotFoundError extends OError {}
|
||||
class PermissionDeniedError extends OError {}
|
||||
class RateLimitError extends OError {}
|
||||
class ProviderRequestError extends OError {}
|
||||
|
||||
export {
|
||||
AlreadyExistsError,
|
||||
GitNotLinkedError,
|
||||
GitConflictError,
|
||||
InvalidTokenError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
RateLimitError,
|
||||
ProviderRequestError,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { fetchJson, fetchStream } from '@overleaf/fetch-utils'
|
||||
import HistoryManager from '../../../../app/src/Features/History/HistoryManager.mjs'
|
||||
|
||||
const urlBase = `${Settings.apis.project_history.url}/project`
|
||||
|
||||
async function latestVersion(projectId) {
|
||||
const url = `${urlBase}/${projectId}/version`
|
||||
try {
|
||||
await HistoryManager.promises.flushProject(projectId)
|
||||
const json = await fetchJson(url)
|
||||
return json.version
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'Failed to get the latest project version from history', { projectId })
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjectSnapshot(projectId, version) {
|
||||
const url = `${urlBase}/${projectId}/version/${version}`
|
||||
try {
|
||||
const snapshot = await fetchJson(url)
|
||||
return snapshot.files || {}
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'Failed to get the project snapshot from history', { projectId })
|
||||
}
|
||||
}
|
||||
|
||||
async function getPathsAtVersion(projectId, version) {
|
||||
const url = `${urlBase}/${projectId}/paths/version/${version}`
|
||||
try {
|
||||
const json = await fetchJson(url)
|
||||
return json
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'Failed to fetch file paths from history', { projectId, version })
|
||||
}
|
||||
}
|
||||
|
||||
// response field 'diff' is an array of objects:
|
||||
// {
|
||||
// pathname: string,
|
||||
// operation: 'added' | 'removed' | 'edited'| 'renamed' | undefined,
|
||||
// ...
|
||||
// }
|
||||
async function getProjectFileTreeDiff(projectId, fromV, toV) {
|
||||
const url = `${urlBase}/${projectId}/filetree/diff?from=${fromV}&to=${toV}`
|
||||
try {
|
||||
const json = await fetchJson(url)
|
||||
return json.diff || []
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'Failed to fetch filetree diff from history', { projectId, fromV, toV })
|
||||
}
|
||||
}
|
||||
|
||||
// return file from history as a base64 encoded string
|
||||
async function getProjectFileBuffer(projectId, version, filePath) {
|
||||
const url = `${urlBase}/${projectId}/version/${version}/${encodeURIComponent(filePath)}`
|
||||
try {
|
||||
const stream = await fetchStream(url)
|
||||
return await _streamToBase64(stream)
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'Failed to fetch file from history', { projectId, version, filePath })
|
||||
}
|
||||
}
|
||||
|
||||
async function _streamToBase64(stream) {
|
||||
const parts = []
|
||||
let leftover = null
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const data = leftover ? Buffer.concat([leftover, chunk]) : chunk
|
||||
|
||||
const remainder = data.length % 3
|
||||
const completeLength = data.length - remainder
|
||||
|
||||
if (completeLength > 0) {
|
||||
parts.push(data.subarray(0, completeLength).toString('base64'))
|
||||
}
|
||||
|
||||
leftover = remainder ? data.subarray(completeLength) : null
|
||||
}
|
||||
|
||||
if (leftover) {
|
||||
parts.push(leftover.toString('base64'))
|
||||
}
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
export default {
|
||||
latestVersion,
|
||||
getPathsAtVersion,
|
||||
getProjectSnapshot,
|
||||
getProjectFileBuffer,
|
||||
getProjectFileTreeDiff,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { GitHubSyncProjectStates } from '../models/githubSyncProjectStates.mjs'
|
||||
import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs'
|
||||
const { normalizeQuery } = Mongo
|
||||
|
||||
function getProjectState(projectId, projection = {}) {
|
||||
return GitHubSyncProjectStates.findOne(normalizeQuery({ projectId }), projection).lean()
|
||||
}
|
||||
|
||||
function createProjectState(projectId, data) {
|
||||
return GitHubSyncProjectStates.create({
|
||||
projectId: normalizeQuery(projectId),
|
||||
...data
|
||||
})
|
||||
}
|
||||
|
||||
function updateProjectState(projectId, data) {
|
||||
return GitHubSyncProjectStates.updateOne(
|
||||
normalizeQuery({ projectId }),
|
||||
{ $set: data },
|
||||
)
|
||||
}
|
||||
|
||||
function removeProjectState(projectId) {
|
||||
return GitHubSyncProjectStates.deleteMany(normalizeQuery({ projectId }))
|
||||
}
|
||||
|
||||
export default {
|
||||
getProjectState,
|
||||
createProjectState,
|
||||
updateProjectState,
|
||||
removeProjectState,
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import AccessTokenEncryptor from '@overleaf/access-token-encryptor'
|
||||
import Mongo from '../../../../app/src/Features/Helpers/Mongo.mjs'
|
||||
import { GitHubSyncUserCredentials } from '../models/githubSyncUserCredentials.mjs'
|
||||
import { InvalidTokenError } from './GitSyncErrors.mjs'
|
||||
|
||||
const { normalizeQuery } = Mongo
|
||||
|
||||
// encrypt / decrypt helpers
|
||||
const accessTokenEncryptor = new AccessTokenEncryptor({
|
||||
cipherLabel: process.env.GITHUB_TOKEN_CIPHER_LABEL || "OL_CEP-v3",
|
||||
cipherPasswords: {
|
||||
[process.env.GITHUB_TOKEN_CIPHER_LABEL || "OL_CEP-v3"]: process.env.GITHUB_TOKEN_CIPHER_PASSWORD,
|
||||
},
|
||||
})
|
||||
|
||||
async function encryptAccessToken(accessToken) {
|
||||
try {
|
||||
return await accessTokenEncryptor.promises.encryptJson(accessToken)
|
||||
} catch (err) {
|
||||
throw OError.tag('failed to encrypt token', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptAccessToken(tokenEncrypted) {
|
||||
try {
|
||||
return await accessTokenEncryptor.promises.decryptToJson(tokenEncrypted)
|
||||
} catch (err) {
|
||||
throw new InvalidTokenError('failed to decrypt token', { status: 401 }, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------- exports -------------------------- //
|
||||
async function getUserToken(userId) {
|
||||
const credentials = await GitHubSyncUserCredentials.findOne(normalizeQuery({ userId }))
|
||||
if (!credentials) throw new InvalidTokenError('no user token', { userId, status: 400 })
|
||||
return await decryptAccessToken(credentials.github)
|
||||
}
|
||||
|
||||
async function saveUserToken(userId, accessToken) {
|
||||
const tokenEncrypted = await encryptAccessToken(accessToken)
|
||||
await GitHubSyncUserCredentials.findOneAndUpdate(
|
||||
normalizeQuery({ userId }),
|
||||
{ $set: { github: tokenEncrypted } },
|
||||
{ upsert: true }
|
||||
)
|
||||
}
|
||||
|
||||
// Try to revoke user's token, then remove it
|
||||
async function removeUserToken(userId) {
|
||||
let token
|
||||
try {
|
||||
const token = await getUserToken(userId)
|
||||
} catch (err) {
|
||||
logger.warn({ err, userId }, 'failed to get user token')
|
||||
}
|
||||
// fire-and-forget, but still handle errors
|
||||
if (token) {
|
||||
GitHubApiClient.revokeToken(token).catch(err => {
|
||||
logger.warn({ err, userId }, 'failed to revoke user token')
|
||||
})
|
||||
}
|
||||
await GitHubSyncUserCredentials.deleteOne(normalizeQuery({ userId }))
|
||||
return
|
||||
}
|
||||
|
||||
export default {
|
||||
saveUserToken,
|
||||
getUserToken,
|
||||
removeUserToken
|
||||
}
|
||||
Reference in New Issue
Block a user