diff --git a/server-ce/Dockerfile b/server-ce/Dockerfile index 7aac1f96b1..20ba037e32 100644 --- a/server-ce/Dockerfile +++ b/server-ce/Dockerfile @@ -75,6 +75,10 @@ RUN --mount=type=cache,target=/root/.cache \ echo "==== PACK LOGS (all attempts failed) ===="; \ find /tmp -name pack.log -exec cat {} \; 2>/dev/null || true; \ exit 1 +# Ensure git is available for the git sync feature +# ------------------------------------------------- +RUN apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* + # Copy runit service startup scripts to its location # -------------------------------------------------- ADD server-ce/runit /etc/service diff --git a/server-ce/config/settings.js b/server-ce/config/settings.js index 9d87799cd9..80ac690b9e 100644 --- a/server-ce/config/settings.js +++ b/server-ce/config/settings.js @@ -58,6 +58,8 @@ const settings = { allowAnonymousReadAndWriteSharing: process.env.OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING === 'true', + enableGitSync: process.env.OVERLEAF_ENABLE_GIT_SYNC === 'true', + // Databases // --------- diff --git a/server-ce/k8s/verso-prod-app.yaml b/server-ce/k8s/verso-prod-app.yaml index 838d3f9307..7a9c934745 100644 --- a/server-ce/k8s/verso-prod-app.yaml +++ b/server-ce/k8s/verso-prod-app.yaml @@ -77,6 +77,8 @@ spec: # them). Public self-registration is also off (CE default). - name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV value: "true" + - name: OVERLEAF_ENABLE_GIT_SYNC + value: "true" # SMTP for password-reset / invite emails. All OVERLEAF_EMAIL_* vars # are loaded from the optional 'verso-smtp' Secret — its keys must be # named exactly like these env vars (see the kubectl create secret diff --git a/services/web/app/src/Features/GitSync/GitSyncController.mjs b/services/web/app/src/Features/GitSync/GitSyncController.mjs new file mode 100644 index 0000000000..cc810702ae --- /dev/null +++ b/services/web/app/src/Features/GitSync/GitSyncController.mjs @@ -0,0 +1,52 @@ +import logger from '@overleaf/logger' +import GitSyncHandler from './GitSyncHandler.mjs' +import { expressify } from '@overleaf/promise-utils' + +async function configureGitSync(req, res) { + const projectId = req.params.project_id + const { remoteUrl } = req.body + + if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') { + return res.status(400).json({ error: 'remoteUrl is required' }) + } + + // Rudimentary safety check — allow https:// and git@… URLs only + const trimmed = remoteUrl.trim() + if (!trimmed.startsWith('https://') && !trimmed.startsWith('git@')) { + return res.status(400).json({ error: 'remoteUrl must start with https:// or git@' }) + } + + await GitSyncHandler.setRemote(projectId, trimmed) + logger.debug({ projectId }, 'git sync: remote URL saved') + res.sendStatus(204) +} + +async function pushToGit(req, res) { + const projectId = req.params.project_id + + const remoteUrl = await GitSyncHandler.getRemote(projectId) + if (!remoteUrl) { + return res.status(400).json({ error: 'No git remote configured for this project' }) + } + + logger.debug({ projectId }, 'git sync: starting push') + try { + await GitSyncHandler.pushToRemote(projectId, remoteUrl) + res.sendStatus(204) + } catch (err) { + logger.warn({ err, projectId }, 'git sync: push failed') + res.status(500).json({ error: err.message }) + } +} + +async function getGitSyncConfig(req, res) { + const projectId = req.params.project_id + const remoteUrl = await GitSyncHandler.getRemote(projectId) + res.json({ remoteUrl: remoteUrl ?? '' }) +} + +export default { + configureGitSync: expressify(configureGitSync), + pushToGit: expressify(pushToGit), + getGitSyncConfig: expressify(getGitSyncConfig), +} diff --git a/services/web/app/src/Features/GitSync/GitSyncHandler.mjs b/services/web/app/src/Features/GitSync/GitSyncHandler.mjs new file mode 100644 index 0000000000..faa9a6e71d --- /dev/null +++ b/services/web/app/src/Features/GitSync/GitSyncHandler.mjs @@ -0,0 +1,89 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { createWriteStream } from 'node:fs' +import { join, dirname } from 'node:path' +import { tmpdir } from 'node:os' +import { spawn } from 'node:child_process' +import { pipeline } from 'node:stream/promises' +import logger from '@overleaf/logger' +import ProjectEntityHandler from '../Project/ProjectEntityHandler.mjs' +import HistoryManager from '../History/HistoryManager.mjs' +import { Project } from '../../models/Project.mjs' +import { ObjectId } from '../../infrastructure/mongodb.mjs' + +async function spawnGit(args, cwd) { + return new Promise((resolve, reject) => { + const proc = spawn('git', args, { + cwd, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }) + let stderr = '' + proc.stderr?.on('data', d => (stderr += d.toString())) + proc.on('close', code => { + if (code !== 0) { + reject(new Error(`git ${args[0]} exited ${code}: ${stderr.trim()}`)) + } else { + resolve() + } + }) + proc.on('error', reject) + }) +} + +async function setRemote(projectId, remoteUrl) { + await Project.updateOne( + { _id: new ObjectId(projectId) }, + { $set: { gitRemote: remoteUrl } } + ) +} + +async function getRemote(projectId) { + const project = await Project.findById(projectId, { gitRemote: 1 }).lean() + return project?.gitRemote ?? null +} + +async function pushToRemote(projectId, remoteUrl) { + const tmpDir = await mkdtemp(join(tmpdir(), 'verso-git-')) + logger.debug({ projectId, tmpDir }, 'git sync: writing project to temp dir') + try { + // Fetch all text documents + const docs = await ProjectEntityHandler.promises.getAllDocs(projectId) + for (const [path, doc] of Object.entries(docs)) { + const rel = path.startsWith('/') ? path.slice(1) : path + const dest = join(tmpDir, rel) + await mkdir(dirname(dest), { recursive: true }) + await writeFile(dest, doc.lines.join('\n')) + } + + // Fetch all binary files + const files = await ProjectEntityHandler.promises.getAllFiles(projectId) + for (const [path, file] of Object.entries(files)) { + const rel = path.startsWith('/') ? path.slice(1) : path + const dest = join(tmpDir, rel) + await mkdir(dirname(dest), { recursive: true }) + const { stream } = + await HistoryManager.promises.requestBlobWithProjectId( + projectId, + file.hash + ) + await pipeline(stream, createWriteStream(dest)) + } + + // Git operations — force-push a single commit so the remote always + // reflects the current Verso state exactly. + await spawnGit(['init', '-b', 'main'], tmpDir) + await spawnGit(['config', 'user.email', 'verso-sync@localhost'], tmpDir) + await spawnGit(['config', 'user.name', 'Verso Sync'], tmpDir) + await spawnGit(['add', '-A'], tmpDir) + await spawnGit( + ['commit', '--allow-empty', '-m', 'Verso sync'], + tmpDir + ) + await spawnGit(['push', '--force', remoteUrl, 'HEAD:main'], tmpDir) + + logger.debug({ projectId }, 'git sync: push complete') + } finally { + await rm(tmpDir, { recursive: true, force: true }) + } +} + +export default { setRemote, getRemote, pushToRemote } diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 3934a98326..9dfe5f60be 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -604,6 +604,7 @@ const _ProjectController = { brandVariationId: 1, overleaf: 1, tokens: 1, + gitRemote: 1, }), userIsMemberOfGroupSubscription: sessionUser ? (async () => @@ -1022,6 +1023,8 @@ const _ProjectController = { imageNames, gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl, gitBridgeEnabled: Features.hasFeature('git-bridge'), + gitSyncEnabled: Boolean(Settings.enableGitSync), + gitRemote: project.gitRemote ?? '', wsUrl, showSupport: Features.hasFeature('support'), showTemplatesServerPro, diff --git a/services/web/app/src/models/Project.mjs b/services/web/app/src/models/Project.mjs index db497eecf6..8350079c91 100644 --- a/services/web/app/src/models/Project.mjs +++ b/services/web/app/src/models/Project.mjs @@ -108,6 +108,7 @@ export const ProjectSchema = new Schema( }, ], deferredTpdsFlushCounter: { type: Number }, + gitRemote: { type: String }, }, { minimize: false } ) diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 431906dd4f..87647a906b 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -68,6 +68,7 @@ import { plainTextResponse } from './infrastructure/Response.mjs' import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs' import ClsiCacheController from './Features/Compile/ClsiCacheController.mjs' import AsyncLocalStorage from './infrastructure/AsyncLocalStorage.mjs' +import GitSyncController from './Features/GitSync/GitSyncController.mjs' const { renderUnsupportedBrowserPage, unsupportedBrowserMiddleware } = UnsupportedBrowserMiddleware @@ -603,6 +604,25 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { ProjectController.updateProjectAdminSettings ) + webRouter.get( + '/project/:project_id/git-sync', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + GitSyncController.getGitSyncConfig + ) + webRouter.post( + '/project/:project_id/git-sync', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + GitSyncController.configureGitSync + ) + webRouter.post( + '/project/:project_id/git-sync/push', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + GitSyncController.pushToGit + ) + webRouter.post( '/project/:Project_id/compile', RateLimiterMiddleware.rateLimit(rateLimiters.compileProjectHttp, { diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index 3131779382..117319ef30 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -14,6 +14,8 @@ meta(name="ol-wikiEnabled" data-type="boolean" content=settings.proxyLearn) meta(name="ol-capabilities" data-type="json" content=capabilities) meta(name="ol-gitBridgePublicBaseUrl" content=gitBridgePublicBaseUrl) meta(name="ol-gitBridgeEnabled" data-type="boolean" content=gitBridgeEnabled) +meta(name="ol-gitSyncEnabled" data-type="boolean" content=gitSyncEnabled) +meta(name="ol-gitRemote" content=gitRemote) meta(name="ol-compilesUserContentDomain" content=settings.compilesUserContentDomain) //- enable doc hash checking for all projects //- used in public/js/libs/sharejs.js diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index c4489e11d0..48dd1a55e4 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -794,6 +794,13 @@ "git_bridge_modal_git_clone_your_project": "", "git_bridge_modal_learn_more_about_authentication_tokens": "", "git_bridge_modal_read_only": "", + "git_sync": "", + "git_sync_description": "", + "git_sync_push_now": "", + "git_sync_pushing": "", + "git_sync_push_success": "", + "git_sync_remote_url": "", + "git_sync_saved": "", "git_bridge_modal_review_access": "", "git_bridge_modal_see_once": "", "git_bridge_modal_use_previous_token": "", diff --git a/services/web/frontend/js/features/ide-react/components/rail/rail.tsx b/services/web/frontend/js/features/ide-react/components/rail/rail.tsx index c722fd51b9..c01949b18a 100644 --- a/services/web/frontend/js/features/ide-react/components/rail/rail.tsx +++ b/services/web/frontend/js/features/ide-react/components/rail/rail.tsx @@ -71,6 +71,7 @@ export const RailLayout = () => { const { features } = useProjectContext() const { isRestrictedTokenMember } = useEditorContext() const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled') + const gitSyncEnabled = getMeta('ol-gitSyncEnabled') const { isOverleaf } = getMeta('ol-ExposedSettings') const { view, setLeftMenuShown } = useLayoutContext() @@ -119,7 +120,7 @@ export const RailLayout = () => { icon: 'integration_instructions', title: t('integrations'), component: , - hide: !isOverleaf && !gitBridgeEnabled, + hide: !isOverleaf && !gitBridgeEnabled && !gitSyncEnabled, }, { key: 'review-panel', @@ -148,6 +149,7 @@ export const RailLayout = () => { isRestrictedTokenMember, isOverleaf, gitBridgeEnabled, + gitSyncEnabled, ] ) diff --git a/services/web/frontend/js/features/integrations-panel/git-sync-widget.tsx b/services/web/frontend/js/features/integrations-panel/git-sync-widget.tsx new file mode 100644 index 0000000000..aa06b4d5db --- /dev/null +++ b/services/web/frontend/js/features/integrations-panel/git-sync-widget.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import { postJSON } from '@/infrastructure/fetch-json' +import OLButton from '@/shared/components/ol/ol-button' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import MaterialIcon from '@/shared/components/material-icon' +import OLNotification from '@/shared/components/ol/ol-notification' + +type Action = 'save' | 'push' +type Status = 'idle' | 'busy' | 'success' | 'error' + +export default function GitSyncWidget() { + const { t } = useTranslation() + const projectId = getMeta('ol-project_id') + const initialRemote = getMeta('ol-gitRemote') + + const [remoteUrl, setRemoteUrl] = useState(initialRemote ?? '') + const [lastAction, setLastAction] = useState('save') + const [status, setStatus] = useState('idle') + const [errorMsg, setErrorMsg] = useState('') + + async function handleSave(e: React.FormEvent) { + e.preventDefault() + setLastAction('save') + setStatus('busy') + setErrorMsg('') + try { + await postJSON(`/project/${projectId}/git-sync`, { + body: { remoteUrl }, + }) + setStatus('success') + } catch (err: any) { + setStatus('error') + setErrorMsg(err?.data?.error ?? String(err)) + } + } + + async function handlePush() { + setLastAction('push') + setStatus('busy') + setErrorMsg('') + try { + await postJSON(`/project/${projectId}/git-sync/push`, { body: {} }) + setStatus('success') + } catch (err: any) { + setStatus('error') + setErrorMsg(err?.data?.error ?? String(err)) + } + } + + const isBusy = status === 'busy' + + return ( +
+
+ + {t('git_sync')} +
+

{t('git_sync_description')}

+ +
+ + {t('git_sync_remote_url')} + setRemoteUrl(e.target.value)} + placeholder="https://user:token@github.com/org/repo.git" + disabled={isBusy} + /> + + +
+ + {t('save')} + + + {t('git_sync_push_now')} + +
+
+ + {status === 'success' && ( + + )} + {status === 'error' && ( + + )} +
+ ) +} diff --git a/services/web/frontend/js/features/integrations-panel/integrations-panel.tsx b/services/web/frontend/js/features/integrations-panel/integrations-panel.tsx index e4d8dc153e..667b4dcf89 100644 --- a/services/web/frontend/js/features/integrations-panel/integrations-panel.tsx +++ b/services/web/frontend/js/features/integrations-panel/integrations-panel.tsx @@ -2,6 +2,8 @@ import { ElementType } from 'react' import importOverleafModules from '../../../macros/import-overleaf-module.macro' import { useTranslation } from 'react-i18next' import RailPanelHeader from '@/features/ide-react/components/rail/rail-panel-header' +import getMeta from '@/utils/meta' +import GitSyncWidget from './git-sync-widget' const integrationPanelComponents = importOverleafModules( 'integrationPanelComponents' @@ -9,10 +11,12 @@ const integrationPanelComponents = importOverleafModules( export default function IntegrationsPanel() { const { t } = useTranslation() + const gitSyncEnabled = getMeta('ol-gitSyncEnabled') return (
+ {gitSyncEnabled && } {integrationPanelComponents.map( ({ import: { default: Component }, path }) => ( diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index ea71ed2358..f797729cfa 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -136,6 +136,8 @@ export interface Meta { 'ol-galleryTagName': string 'ol-gitBridgeEnabled': boolean 'ol-gitBridgePublicBaseUrl': string + 'ol-gitSyncEnabled': boolean + 'ol-gitRemote': string 'ol-github': { enabled: boolean; error: boolean } 'ol-groupAuditLogs': [] 'ol-groupDomains': [] diff --git a/services/web/frontend/stylesheets/components/integrations-panel.scss b/services/web/frontend/stylesheets/components/integrations-panel.scss index 9b6510b8ab..86a1ae3458 100644 --- a/services/web/frontend/stylesheets/components/integrations-panel.scss +++ b/services/web/frontend/stylesheets/components/integrations-panel.scss @@ -99,3 +99,33 @@ color: var(--integrations-panel-description-color); margin-bottom: 0; } + +.git-sync-widget { + padding: var(--spacing-04); + border-bottom: 1px solid var(--integrations-panel-card-border); + + .git-sync-widget-header { + display: flex; + align-items: center; + gap: var(--spacing-02); + margin-bottom: var(--spacing-02); + } + + .git-sync-widget-title { + font-size: var(--font-size-02); + font-weight: 600; + color: var(--integrations-panel-color); + } + + .git-sync-widget-description { + font-size: var(--font-size-01); + color: var(--integrations-panel-description-color); + margin-bottom: var(--spacing-04); + } + + .git-sync-widget-actions { + display: flex; + gap: var(--spacing-02); + margin-top: var(--spacing-02); + } +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 84e2151405..b4a34f27ba 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1043,6 +1043,13 @@ "git_bridge_modal_git_clone_your_project": "Git clone your project by using the link below and a Git authentication token", "git_bridge_modal_learn_more_about_authentication_tokens": "Learn more about Git integration authentication tokens.", "git_bridge_modal_read_only": "You have read-only access to this project. This means you can pull from __appName__ but you can’t push any changes you make back to this project.", + "git_sync": "Git Sync", + "git_sync_description": "Force-push your project files to an external git repository. Include your auth token in the URL (e.g. https://user:token@github.com/org/repo.git).", + "git_sync_push_now": "Push now", + "git_sync_pushing": "Pushing…", + "git_sync_push_success": "Project pushed to git remote successfully.", + "git_sync_remote_url": "Remote URL (with auth token)", + "git_sync_saved": "Remote URL saved.", "git_bridge_modal_review_access": "<0>You have review access to this project. This means you can pull from __appName__ but you can’t push any changes you make back to this project.", "git_bridge_modal_see_once": "You’ll only see this token once. To delete it or generate a new one, visit Account settings. For detailed instructions and troubleshooting, read our <0>help page.", "git_bridge_modal_use_previous_token": "If you’re prompted for a password, you can use a previously generated Git authentication token. Or you can generate a new one in Account settings. For more support, read our <0>help page.", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index 0d7c764170..f0a34e9eed 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -1046,6 +1046,13 @@ "git_bridge_modal_git_clone_your_project": "Git clonez votre projet en utilisant le lien ci-dessous et un jeton d'authentification Git", "git_bridge_modal_learn_more_about_authentication_tokens": "Apprenez-en davantage sur les jetons d’authentification de l’intégration Git.", "git_bridge_modal_read_only": "Vous disposez d'un accès en lecture seule à ce projet. Cela signifie que vous pouvez extraire des données de __appName__, mais que vous ne pouvez pas transférer les modifications que vous apportez à ce projet.", + "git_sync": "Synchronisation Git", + "git_sync_description": "Pousse de force les fichiers du projet vers un dépôt git externe. Incluez votre jeton d'authentification dans l'URL (ex. https://user:token@github.com/org/repo.git).", + "git_sync_push_now": "Pousser maintenant", + "git_sync_pushing": "Envoi en cours…", + "git_sync_push_success": "Projet envoyé vers le dépôt git avec succès.", + "git_sync_remote_url": "URL distante (avec jeton d'auth)", + "git_sync_saved": "URL distante sauvegardée.", "git_bridge_modal_review_access": "<0>You have review access to this project. Cela signifie que vous pouvez extraire de __appName__ mais vous ne pouvez pas repousser les modifications que vous apportez à ce projet.", "git_bridge_modal_see_once": "Vous n’allez voir ce jeton qu’une seule fois. Pour l’effacer ou pour en générer un nouveau, allez dans Paramètres du compte. Pour des instructions détaillées ou pour résoudre des problèmes, lisez notre <0>page d’aide.", "git_bridge_modal_use_previous_token": "Si un mot de passe vous est demandé, vous pouvez utiliser un jeton Git précédent ou en générer un nouveau dans Paramètres du compte. Pour plus d’informations, lisez notre <0>page d’aide.",