diff --git a/services/web/modules/github-sync/app/src/AccessTokenEncryptorHelper.mjs b/services/web/modules/github-sync/app/src/AccessTokenEncryptorHelper.mjs new file mode 100644 index 0000000000..346d81ca84 --- /dev/null +++ b/services/web/modules/github-sync/app/src/AccessTokenEncryptorHelper.mjs @@ -0,0 +1,82 @@ +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import fs from 'node:fs' +import crypto from 'node:crypto' +import Path from 'node:path' +import AccessTokenEncryptorClass from '@overleaf/access-token-encryptor' + +const TOKEN_CIPHER_FILE = '/var/lib/overleaf/data/.token-cipher.json' +const TOKEN_CIPHER_LABEL = 'OL_CEP-v3' + +let encryptorInstance = null + +/** + * Get or create a stable encryptor data that persists across container + * recreations. Priority: + * 1. GITHUB_CIPHER_PASSWORD || TOKEN_CIPHER_PASSWORD env var (explicit user config) + * 2. JSON file with encryptor data in the persistent volume (/var/lib/overleaf/data/) + * — auto-generated on first use, survives container rebuilds + */ +function _getEncryptorData() { + const cipherPassword = process.env.GITHUB_TOKEN_CIPHER_PASSWORD || process.env.TOKEN_CIPHER_PASSWORD + const cipherLabel = process.env.GITHUB_TOKEN_CIPHER_LABEL || + process.env.TOKEN_CIPHER_LABEL || + TOKEN_CIPHER_LABEL + if (cipherPassword) { + return { + cipherLabel, + cipherPasswords: { + [cipherLabel]: cipherPassword + } + } + } + // cipherPassword is not set, use file + const cipherFile = process.env.GITHUB_TOKEN_CIPHER_FILE || + process.env.TOKEN_CIPHER_FILE || + TOKEN_CIPHER_FILE + try { + return JSON.parse(fs.readFileSync(cipherFile, 'utf8')) + } catch (err) { + if (err.code === 'ENOENT') { + logger.info({ cipherFile }, 'Creating new cipherFile') + } else { + logger.error({ err, cipherFile }, 'Bad cipherFile') + throw err + } + } + // File doesn't exist yet — generate one + try { + const encryptorData = { + cipherLabel, + cipherPasswords: { + [cipherLabel]: crypto.randomBytes(32).toString('base64') + }, + } + + const dir = Path.dirname(cipherFile) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(cipherFile, JSON.stringify(encryptorData, null, 2), { mode: 0o600 }) + return encryptorData + + } catch (err) { + logger.error({ err, cipherFile }, 'Cannot create cipherFile') + throw err + } +} + +function _getEncryptor() { + if (!encryptorInstance) { + const encryptorData = _getEncryptorData() + encryptorInstance = new AccessTokenEncryptorClass(encryptorData) + } + return encryptorInstance +} + +export const AccessTokenEncryptor = { + async encryptJson(json) { + return await _getEncryptor().promises.encryptJson(json) + }, + async decryptToJson(encryptedJson) { + return await _getEncryptor().promises.decryptToJson(encryptedJson) + }, +} diff --git a/services/web/modules/github-sync/app/src/GitMerge.mjs b/services/web/modules/github-sync/app/src/GitMerge.mjs index 2d60cfcb28..2a9c70f08a 100644 --- a/services/web/modules/github-sync/app/src/GitMerge.mjs +++ b/services/web/modules/github-sync/app/src/GitMerge.mjs @@ -311,7 +311,7 @@ async function resolveConflictSyncState( } } -// ----------- detached flow +// ----------- detached flow // GH repo was force pushed, lastSybc commit possibly anavailable async function resolveDetachedSyncState( token, @@ -574,7 +574,7 @@ async function buildDetachedSyncPlan({ buffer = await HistoryManager.getProjectFileBuffer(projectId, currentVersion, path) } const sha = await api.uploadBlob(token, repoFullName, buffer) - // assert sha !== localHash + // assert sha !== localHash } // OL == GH, do nothing diff --git a/services/web/modules/github-sync/app/src/TokenManager.mjs b/services/web/modules/github-sync/app/src/TokenManager.mjs index 2f9006ed60..a8a2ddedb7 100644 --- a/services/web/modules/github-sync/app/src/TokenManager.mjs +++ b/services/web/modules/github-sync/app/src/TokenManager.mjs @@ -1,23 +1,15 @@ 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 { AccessTokenEncryptor } from './AccessTokenEncryptorHelper.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) + return await AccessTokenEncryptor.encryptJson(accessToken) } catch (err) { throw OError.tag('failed to encrypt token', err) } @@ -25,7 +17,7 @@ async function encryptAccessToken(accessToken) { async function decryptAccessToken(tokenEncrypted) { try { - return await accessTokenEncryptor.promises.decryptToJson(tokenEncrypted) + return await AccessTokenEncryptor.decryptToJson(tokenEncrypted) } catch (err) { throw new InvalidTokenError('failed to decrypt token', { status: 401 }, err) } diff --git a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-merge-modal.tsx b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-merge-modal.tsx index 98f80f0438..ace565d7cd 100644 --- a/services/web/modules/github-sync/frontend/js/components/modals/git-sync-merge-modal.tsx +++ b/services/web/modules/github-sync/frontend/js/components/modals/git-sync-merge-modal.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { debugConsole } from '@/utils/debugging' import { postJSON } from '@/infrastructure/fetch-json' import useAsync from '@/shared/hooks/use-async' +import clientId from '@/utils/client-id' import { OLModalBody, OLModalFooter, @@ -11,6 +12,8 @@ import OLButton from '@/shared/components/ol/ol-button' import OLNotification from '@/shared/components/ol/ol-notification' import { GitSyncModalStatus, ProjectSyncState } from '../../types/git-sync-types' +import { useReferencesContext } from '@/features/ide-react/context/references-context' + type GitSyncMergeModalProps = { handleHide: () => void modalStatus: GitSyncModalStatus @@ -29,10 +32,11 @@ const GitSyncMergeModal = ({ commitMessage, setCommitMessage, projectId -}: GitSynMergeModalProps) => { +}: GitSyncMergeModalProps) => { const { t } = useTranslation() const { error, isError, isLoading, setError, runAsync } = useAsync() + const { indexAllReferences } = useReferencesContext() useEffect(() => { if (modalStatus !== 'run-merge' && @@ -46,11 +50,17 @@ const GitSyncMergeModal = ({ body: { message: commitMessage, claimConflictIsResolved }, }) ) - .then(data => { + .then(async data => { switch (data.mergeStatus) { case 'clean': setModalStatus('merge-overview') + + await postJSON(`/project/${projectId}/flush`) + .catch(e => debugConsole.error(e)) + + indexAllReferences(true) + break case 'conflict': diff --git a/services/web/modules/github-sync/index.mjs b/services/web/modules/github-sync/index.mjs index 20f79cc899..98ec913816 100644 --- a/services/web/modules/github-sync/index.mjs +++ b/services/web/modules/github-sync/index.mjs @@ -1,14 +1,11 @@ import Settings from '@overleaf/settings' import Modules from '../../app/src/infrastructure/Modules.mjs' import logger from '@overleaf/logger' -import GitHubSyncRouter from './app/src/GitHubSyncRouter.mjs' -import SyncStateManager from './app/src/SyncStateManager.mjs' -import TokenManager from './app/src/TokenManager.mjs' let GitHubSyncModule = {} if (process.env.GITHUB_SYNC_ENABLED?.toLowerCase() === 'true') { logger.debug({}, 'Enabling GitHub Sync module') -/* + const [{ default: GitHubSyncRouter }, { default: SyncStateManager }, { default: TokenManager } @@ -18,17 +15,15 @@ if (process.env.GITHUB_SYNC_ENABLED?.toLowerCase() === 'true') { import('./app/src/SyncStateManager.mjs'), import('./app/src/TokenManager.mjs'), ]) -*/ - // Delete project sync state from mongo (hook 'projectExpired') - - const siteUrl = process.env.OVERLEAF_SITE_URL?.replace(/\/+$/, '') || 'http://localhost' + const siteUrl = Settings.siteUrl.replace(/\/+$/, '') || 'http://localhost' Settings.githubSync = { clientID: process.env.GITHUB_SYNC_CLIENT_ID, clientSecret: process.env.GITHUB_SYNC_CLIENT_SECRET, callbackURL: `${siteUrl}/user/github-sync/oauth2/callback`, }, + // Delete project sync state from mongo (hook 'projectExpired') Modules.hooks.attach('projectExpired', async projectId => { try { await SyncStateManager.removeProjectState(projectId)