diff --git a/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs b/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs new file mode 100644 index 00000000000..f9de884d36e --- /dev/null +++ b/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs @@ -0,0 +1,611 @@ +// Controller for API v0 endpoints used by git-bridge +// These endpoints provide git-bridge with access to project data, versions, and snapshots + +import { callbackify } from 'node:util' +import { expressify } from '@overleaf/promise-utils' +import logger from '@overleaf/logger' +import { fetchJson, fetchStream } from '@overleaf/fetch-utils' +import settings from '@overleaf/settings' +import ProjectGetter from '../Project/ProjectGetter.mjs' +import HistoryManager from '../History/HistoryManager.mjs' +import UserGetter from '../User/UserGetter.js' +import { Snapshot } from 'overleaf-editor-core' +import Errors from '../Errors/Errors.js' +import EditorController from '../Editor/EditorController.mjs' +import ProjectEntityHandler from '../Project/ProjectEntityHandler.mjs' +import FileTypeManager from '../Uploads/FileTypeManager.js' +import crypto from 'node:crypto' +import fs from 'node:fs' +import fsPromises from 'node:fs/promises' +import { pipeline } from 'node:stream/promises' +import Path from 'node:path' + +/** + * GET /api/v0/docs/:project_id + * Returns the latest version info for a project + */ +async function getDoc(req, res, next) { + const projectId = req.params.project_id + + try { + // Get project + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + owner_ref: 1, + }) + + if (!project) { + return res.status(404).json({ message: 'Project not found' }) + } + + // Get latest history version + const historyId = await HistoryManager.promises.getHistoryId(projectId) + const latestHistory = await HistoryManager.promises.getLatestHistory( + projectId + ) + + if (!latestHistory || !latestHistory.updates) { + // No history yet, return minimal response + return res.json({ + latestVerId: 0, + latestVerAt: new Date().toISOString(), + latestVerBy: null, + }) + } + + // Get the most recent update + const updates = latestHistory.updates + const latestUpdate = updates[0] // updates are sorted newest first + + let latestVerBy = null + if (latestUpdate.meta && latestUpdate.meta.users) { + const userId = latestUpdate.meta.users[0] + if (userId) { + const user = await UserGetter.promises.getUser(userId, { + email: 1, + first_name: 1, + last_name: 1, + }) + if (user) { + const name = [user.first_name, user.last_name] + .filter(Boolean) + .join(' ') + latestVerBy = { + email: user.email, + name: name || user.email, + } + } + } + } + + const response = { + latestVerId: latestUpdate.toV || 0, + latestVerAt: latestUpdate.meta.end_ts + ? new Date(latestUpdate.meta.end_ts).toISOString() + : new Date().toISOString(), + latestVerBy, + } + + res.json(response) + } catch (err) { + logger.error({ err, projectId }, 'Error getting doc info') + next(err) + } +} + +/** + * GET /api/v0/docs/:project_id/saved_vers + * Returns the list of saved versions (labels) for a project + */ +async function getSavedVers(req, res, next) { + const projectId = req.params.project_id + + try { + // Get project to verify it exists + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + }) + + if (!project) { + return res.status(404).json({ message: 'Project not found' }) + } + + // Get labels from project-history service + let labels + try { + labels = await fetchJson( + `${settings.apis.project_history.url}/project/${projectId}/labels` + ) + } catch (err) { + // If no labels exist, return empty array + if (err.response?.status === 404) { + labels = [] + } else { + throw err + } + } + + // Enrich labels with user information + labels = await enrichLabels(labels) + + // Transform to git-bridge format + const savedVers = labels.map(label => ({ + versionId: label.version, + comment: label.comment, + user: { + email: label.user_display_name || label.user?.email || 'unknown', + name: label.user_display_name || label.user?.name || 'unknown', + }, + createdAt: label.created_at, + })) + + res.json(savedVers) + } catch (err) { + logger.error({ err, projectId }, 'Error getting saved versions') + next(err) + } +} + +/** + * GET /api/v0/docs/:project_id/snapshots/:version + * Returns the snapshot (file contents) for a specific version + */ +async function getSnapshot(req, res, next) { + const projectId = req.params.project_id + const version = parseInt(req.params.version, 10) + + try { + // Get project to verify it exists + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + }) + + if (!project) { + return res.status(404).json({ message: 'Project not found' }) + } + + // Get snapshot content from history service + const snapshotRaw = await HistoryManager.promises.getContentAtVersion( + projectId, + version + ) + + const snapshot = Snapshot.fromRaw(snapshotRaw) + + // Build response in git-bridge format + // Note: srcs and atts are arrays of arrays: [[content, path], [content, path], ...] + const srcs = [] + const atts = [] + + // Process all files in the snapshot + const files = snapshot.getFileMap() + for (const [pathname, file] of files) { + if (file.isEditable()) { + // Text file - include content directly as [content, path] array + srcs.push([file.getContent(), pathname]) + } else { + // Binary file - provide URL to download as [url, path] array + const hash = file.getHash() + + // Build URL to blob endpoint (already exists in web service) + const blobUrl = `${settings.siteUrl}/project/${projectId}/blob/${hash}` + + atts.push([blobUrl, pathname]) + } + } + + const response = { + srcs, + atts, + } + + res.json(response) + } catch (err) { + if (err instanceof Errors.NotFoundError) { + return res.status(404).json({ message: 'Version not found' }) + } + logger.error({ err, projectId, version }, 'Error getting snapshot') + next(err) + } +} + +/** + * POST /api/v0/docs/:project_id/snapshots + * Receives a push from git-bridge with file changes + */ +async function postSnapshot(req, res, next) { + const projectId = req.params.project_id + const { latestVerId, files, postbackUrl } = req.body + + // Use git-bridge user ID (system user) for operations + // If not configured, operations will be performed as system user (null) + const userId = settings.gitBridgeUserId ?? null + + try { + // Get project to verify it exists + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + rootFolder: 1, + }) + + if (!project) { + return res.status(404).json({ message: 'Project not found' }) + } + + // Validate latestVerId matches current version + const latestHistory = await HistoryManager.promises.getLatestHistory( + projectId + ) + + let currentVersion = 0 + if (latestHistory && latestHistory.updates && latestHistory.updates.length > 0) { + currentVersion = latestHistory.updates[0].toV || 0 + } + + if (latestVerId !== currentVersion) { + // Version mismatch - return 409 Conflict + logger.info( + { projectId, latestVerId, currentVersion }, + 'Push rejected: version out of date' + ) + + // Send response immediately + res.status(409).json({ + status: 409, + code: 'outOfDate', + message: 'Out of Date', + }) + + // Postback the out of date result + if (postbackUrl) { + await sendPostback(postbackUrl, { + code: 'outOfDate', + message: 'Out of Date', + }) + } + + return + } + + // Accept the push request immediately (202 Accepted) + res.status(202).json({ + status: 202, + code: 'accepted', + message: 'Accepted', + }) + + // Process the push asynchronously + processSnapshotPush(projectId, files, postbackUrl, userId).catch(err => { + logger.error({ err, projectId }, 'Error processing snapshot push') + }) + } catch (err) { + logger.error({ err, projectId }, 'Error posting snapshot') + next(err) + } +} + +/** + * Process the snapshot push asynchronously + */ +async function processSnapshotPush(projectId, files, postbackUrl, userId) { + try { + logger.info({ projectId, fileCount: files.length }, 'Processing snapshot push') + + // Get all current entities to determine what needs to be deleted + const { docs, files: existingFiles } = + await ProjectEntityHandler.promises.getAllEntities(projectId) + + const existingPaths = new Set() + docs.forEach(doc => existingPaths.add(doc.path)) + existingFiles.forEach(file => existingPaths.add(file.path)) + + // Track which paths are in the new snapshot + const newPaths = new Set(files.map(f => f.name)) + + // Validate files first + const invalidFiles = [] + for (const file of files) { + const validation = validateFilePath(file.name) + if (!validation.valid) { + invalidFiles.push({ + file: file.name, + state: validation.state, + cleanFile: validation.cleanPath, + }) + } + } + + if (invalidFiles.length > 0) { + logger.warn({ projectId, invalidFiles }, 'Invalid files in push') + await sendPostback(postbackUrl, { + code: 'invalidFiles', + errors: invalidFiles, + }) + return + } + + // Process file updates/creations + for (const file of files) { + if (file.url) { + // File has been modified - download and update it + await processFileUpdate(projectId, file.name, file.url, userId) + } + // If no URL, file exists but hasn't changed - no action needed + } + + // Delete files that are no longer in the snapshot + const pathsToDelete = [...existingPaths].filter(path => !newPaths.has(path)) + for (const path of pathsToDelete) { + try { + await EditorController.promises.deleteEntityWithPath( + projectId, + path, + 'git-bridge', + userId + ) + logger.debug({ projectId, path }, 'Deleted file from project') + } catch (err) { + logger.warn({ err, projectId, path }, 'Failed to delete file') + } + } + + // Get new version after updates + const updatedHistory = await HistoryManager.promises.getLatestHistory( + projectId + ) + let newVersion = 0 + if (updatedHistory && updatedHistory.updates && updatedHistory.updates.length > 0) { + newVersion = updatedHistory.updates[0].toV || 0 + } + + // Send success postback + await sendPostback(postbackUrl, { + code: 'upToDate', + latestVerId: newVersion, + }) + + logger.info({ projectId, newVersion }, 'Snapshot push completed successfully') + } catch (err) { + logger.error({ err, projectId }, 'Error in processSnapshotPush') + + // Send error postback + if (postbackUrl) { + try { + await sendPostback(postbackUrl, { + code: 'error', + message: 'Unexpected Error', + }) + } catch (postbackErr) { + logger.error({ err: postbackErr, projectId }, 'Failed to send error postback') + } + } + } +} + +/** + * Process a single file update + */ +async function processFileUpdate(projectId, filePath, fileUrl, userId) { + let fsPath = null + + try { + // Download file to temporary location + fsPath = await downloadFile(projectId, fileUrl) + + // Determine if this should be a doc or binary file + const fileType = await determineFileType(projectId, filePath, fsPath) + + if (fileType === 'doc') { + // Process as text document + const docLines = await readFileIntoTextArray(fsPath) + await EditorController.promises.upsertDocWithPath( + projectId, + filePath, + docLines, + 'git-bridge', + userId + ) + logger.debug({ projectId, filePath }, 'Updated doc from git-bridge') + } else { + // Process as binary file + await EditorController.promises.upsertFileWithPath( + projectId, + filePath, + fsPath, + null, // linkedFileData + 'git-bridge', + userId + ) + logger.debug({ projectId, filePath }, 'Updated file from git-bridge') + } + } finally { + // Clean up temporary file + if (fsPath) { + try { + await fsPromises.unlink(fsPath) + } catch (err) { + logger.warn({ err, fsPath }, 'Failed to delete temporary file') + } + } + } +} + +/** + * Download a file from URL to temporary location + */ +async function downloadFile(projectId, url) { + const fsPath = Path.join( + settings.path.dumpFolder, + `${projectId}_${crypto.randomUUID()}` + ) + + const writeStream = fs.createWriteStream(fsPath) + + try { + const readStream = await fetchStream(url) + await pipeline(readStream, writeStream) + return fsPath + } catch (err) { + // Clean up on error + try { + await fsPromises.unlink(fsPath) + } catch (unlinkErr) { + logger.warn({ err: unlinkErr, fsPath }, 'Failed to delete file after download error') + } + throw err + } +} + +/** + * Determine if a file should be treated as a doc or binary file + */ +async function determineFileType(projectId, path, fsPath) { + // Check if there is an existing file with the same path + const { docs, files } = + await ProjectEntityHandler.promises.getAllEntities(projectId) + + const existingDoc = docs.find(d => d.path === path) + const existingFile = files.find(f => f.path === path) + const existingFileType = existingDoc ? 'doc' : existingFile ? 'file' : null + + // Determine whether the update should create a doc or binary file + const { binary, encoding } = await FileTypeManager.promises.getType( + path, + fsPath, + existingFileType + ) + + // If we receive a non-utf8 encoding, treat as binary + const isBinary = binary || encoding !== 'utf-8' + + // If a binary file already exists, always keep it as a binary file + if (existingFileType === 'file') { + return 'file' + } else { + return isBinary ? 'file' : 'doc' + } +} + +/** + * Read a file into an array of lines + */ +async function readFileIntoTextArray(fsPath) { + let content = await fsPromises.readFile(fsPath, 'utf8') + if (content === null || content === undefined) { + content = '' + } + const lines = content.split(/\r\n|\n|\r/) + return lines +} + +/** + * Validate a file path + */ +function validateFilePath(path) { + // Check for invalid characters or patterns + // Git-bridge already handles most validation, but we do basic checks + + if (!path || path.length === 0) { + return { valid: false, state: 'error' } + } + + // Check for null bytes + if (path.includes('\0')) { + return { valid: false, state: 'error' } + } + + // Check for suspicious patterns + if (path.includes('..') || path.startsWith('/')) { + return { valid: false, state: 'error' } + } + + // Check for .git directory + if (path.startsWith('.git/') || path === '.git') { + return { valid: false, state: 'disallowed' } + } + + return { valid: true } +} + +/** + * Send postback notification to git-bridge + */ +async function sendPostback(postbackUrl, data) { + if (!postbackUrl) { + return + } + + try { + await fetchJson(postbackUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }) + logger.debug({ postbackUrl, data }, 'Postback sent successfully') + } catch (err) { + logger.error( + { err, postbackUrl, data }, + 'Failed to send postback to git-bridge' + ) + throw err + } +} + +/** + * Enrich labels with user information + */ +async function enrichLabels(labels) { + if (!labels || !labels.length) { + return [] + } + + // Get unique user IDs + const uniqueUsers = new Set(labels.map(label => label.user_id)) + uniqueUsers.delete(null) + uniqueUsers.delete(undefined) + + // Fetch user details + const userDetailsMap = new Map() + for (const userId of uniqueUsers) { + try { + const user = await UserGetter.promises.getUser(userId, { + email: 1, + first_name: 1, + last_name: 1, + }) + if (user) { + const name = [user.first_name, user.last_name] + .filter(Boolean) + .join(' ') + userDetailsMap.set(userId.toString(), { + email: user.email, + name: name || user.email, + }) + } + } catch (err) { + logger.warn({ err, userId }, 'Failed to get user details for label') + } + } + + // Enrich labels + return labels.map(label => { + const enrichedLabel = { ...label } + if (label.user_id) { + const userDetails = userDetailsMap.get(label.user_id.toString()) + if (userDetails) { + enrichedLabel.user = userDetails + enrichedLabel.user_display_name = userDetails.name + } + } + return enrichedLabel + }) +} + +export default { + getDoc: expressify(getDoc), + getSavedVers: expressify(getSavedVers), + getSnapshot: expressify(getSnapshot), + postSnapshot: expressify(postSnapshot), +} diff --git a/services/web/app/src/Features/GitBridge/README.md b/services/web/app/src/Features/GitBridge/README.md new file mode 100644 index 00000000000..df99e62464d --- /dev/null +++ b/services/web/app/src/Features/GitBridge/README.md @@ -0,0 +1,192 @@ +# Git Bridge API v0 Implementation + +## Overview + +This implementation provides the API v0 endpoints required by the Git Bridge service to synchronize Overleaf projects with Git repositories. + +## Implemented Endpoints + +### 1. GET /api/v0/docs/:project_id + +Returns the latest version information for a project. + +**Response Format:** +```json +{ + "latestVerId": 243, + "latestVerAt": "2014-11-30T18:40:58.123Z", + "latestVerBy": { + "email": "user@example.com", + "name": "User Name" + } +} +``` + +**Implementation Details:** +- Retrieves project information from ProjectGetter +- Gets latest history update from HistoryManager +- Enriches with user information from UserGetter +- Returns null for `latestVerBy` if no user information available + +### 2. GET /api/v0/docs/:project_id/saved_vers + +Returns the list of saved versions (labels) for a project. + +**Response Format:** +```json +[ + { + "versionId": 243, + "comment": "added more info on doc GET", + "user": { + "email": "user@example.com", + "name": "User Name" + }, + "createdAt": "2014-11-30T18:47:01.456Z" + } +] +``` + +**Implementation Details:** +- Fetches labels from project-history service +- Enriches labels with user information +- Handles 404 errors by returning empty array +- Transforms to git-bridge expected format + +### 3. GET /api/v0/docs/:project_id/snapshots/:version + +Returns the snapshot (file contents) for a specific version. + +**Response Format:** +```json +{ + "srcs": [ + ["file content here", "path/to/file.tex"], + ["another file", "main.tex"] + ], + "atts": [ + ["https://example.com/blob/hash", "image.png"] + ] +} +``` + +**Implementation Details:** +- Gets snapshot content from HistoryManager +- Uses overleaf-editor-core Snapshot class to parse +- Separates editable files (srcs) from binary files (atts) +- Provides blob URLs for binary files +- **Note:** Arrays of arrays format is required by git-bridge + +### 4. POST /api/v0/docs/:project_id/snapshots + +Receives push requests from git-bridge with file changes. + +**Status:** ✅ Fully implemented + +**Request Format:** +```json +{ + "latestVerId": 123, + "files": [ + { + "name": "path/to/file.tex", + "url": "http://example.com/download/file" + }, + { + "name": "unchanged.tex" + } + ], + "postbackUrl": "http://git-bridge/postback" +} +``` + +**Response (Immediate):** +- 202 Accepted: Push accepted and being processed +- 409 Conflict: Version is out of date (latestVerId doesn't match current version) +- 404 Not Found: Project not found + +**Postback Data (Async):** +On success: +```json +{ + "code": "upToDate", + "latestVerId": 124 +} +``` + +On version conflict: +```json +{ + "code": "outOfDate", + "message": "Out of Date" +} +``` + +On invalid files: +```json +{ + "code": "invalidFiles", + "errors": [ + { + "file": "invalid/../../file.tex", + "state": "error" + } + ] +} +``` + +On unexpected error: +```json +{ + "code": "error", + "message": "Unexpected Error" +} +``` + +**Implementation Details:** +- Validates latestVerId against current project version +- Downloads files from provided URLs +- Determines file type (doc vs binary) automatically +- Updates/creates files using EditorController +- Deletes files not present in new snapshot +- Sends postback notification with results +- Processes asynchronously after accepting request + +## Security + +All endpoints are protected with authorization middleware: +- Read endpoints: `AuthorizationMiddleware.ensureUserCanReadProject` +- Write endpoints: `AuthorizationMiddleware.ensureUserCanWriteProjectContent` + +## Error Handling + +- 202: Push accepted (POST endpoint) +- 404: Project not found or version not found +- 403: User does not have permission +- 409: Version conflict (POST endpoint) +- 500: Internal server error (logged with context) + +## Testing + +Testing can be done by: +1. Starting the web service +2. Using git-bridge to: + - Clone a project (tests GET endpoints) + - Make changes and push (tests POST endpoint) +3. Verifying the API endpoints return correct data +4. Checking logs for postback notifications + +## Future Improvements + +1. **Add unit tests**: Create comprehensive unit tests for all endpoints +2. **Add integration tests**: Test with actual git-bridge service +3. **Performance optimization**: Consider caching for frequently accessed snapshots +4. **Rate limiting**: Add specific rate limiters for git-bridge endpoints +5. **Metrics**: Add prometheus metrics for API usage +6. **Enhanced validation**: Add more sophisticated file name validation + +## References + +- Git Bridge source: `/services/git-bridge/` +- Test data: `/services/git-bridge/src/test/resources/.../state.json` +- Git Bridge API documentation in test files diff --git a/services/web/app/src/Features/GitBridge/USAGE.md b/services/web/app/src/Features/GitBridge/USAGE.md new file mode 100644 index 00000000000..59fae411c4e --- /dev/null +++ b/services/web/app/src/Features/GitBridge/USAGE.md @@ -0,0 +1,328 @@ +# Git Bridge API v0 - Usage Guide + +## Overview + +This guide explains how to use the newly implemented API v0 endpoints for Git Bridge integration. + +## Prerequisites + +1. Overleaf web service running +2. Git Bridge service configured to point to the web service +3. Valid project ID and user authentication + +## API Endpoints + +### Authentication + +All API v0 endpoints use the same authentication mechanism as other Overleaf API endpoints: +- OAuth2 authentication (if configured) +- Session-based authentication via cookies +- HTTP Basic Auth (if configured) + +### 1. Get Project Latest Version + +**Endpoint:** `GET /api/v0/docs/:project_id` + +**Description:** Retrieves the latest version information for a project. + +**Example:** +```bash +curl -X GET "http://localhost:3000/api/v0/docs/507f1f77bcf86cd799439011" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**Response:** +```json +{ + "latestVerId": 243, + "latestVerAt": "2014-11-30T18:40:58.123Z", + "latestVerBy": { + "email": "user@example.com", + "name": "John Doe" + } +} +``` + +### 2. Get Saved Versions (Labels) + +**Endpoint:** `GET /api/v0/docs/:project_id/saved_vers` + +**Description:** Retrieves all saved versions (labels) for a project. + +**Example:** +```bash +curl -X GET "http://localhost:3000/api/v0/docs/507f1f77bcf86cd799439011/saved_vers" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**Response:** +```json +[ + { + "versionId": 243, + "comment": "Final version before submission", + "user": { + "email": "user@example.com", + "name": "John Doe" + }, + "createdAt": "2014-11-30T18:47:01.456Z" + }, + { + "versionId": 185, + "comment": "Draft version", + "user": { + "email": "user@example.com", + "name": "John Doe" + }, + "createdAt": "2014-11-11T17:18:40.789Z" + } +] +``` + +### 3. Get Snapshot for Version + +**Endpoint:** `GET /api/v0/docs/:project_id/snapshots/:version` + +**Description:** Retrieves the complete file content for a specific version. + +**Example:** +```bash +curl -X GET "http://localhost:3000/api/v0/docs/507f1f77bcf86cd799439011/snapshots/243" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**Response:** +```json +{ + "srcs": [ + [ + "\\documentclass{article}\n\\begin{document}\nHello World\n\\end{document}", + "main.tex" + ], + [ + "This is chapter 1", + "chapters/chapter1.tex" + ] + ], + "atts": [ + [ + "http://localhost:3000/project/507f1f77bcf86cd799439011/blob/abc123def456", + "images/figure1.png" + ] + ] +} +``` + +**Note:** +- `srcs` contains text files as `[content, path]` arrays +- `atts` contains binary files as `[url, path]` arrays where the URL can be used to download the file + +### 4. Push Snapshot + +**Endpoint:** `POST /api/v0/docs/:project_id/snapshots` + +**Status:** ✅ Fully Implemented + +**Description:** Pushes file changes from git repository to Overleaf project. + +**Request:** +```bash +curl -X POST "http://localhost:3000/api/v0/docs/507f1f77bcf86cd799439011/snapshots" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "latestVerId": 243, + "files": [ + { + "name": "main.tex", + "url": "http://git-bridge/files/abc123" + }, + { + "name": "chapters/chapter1.tex" + } + ], + "postbackUrl": "http://git-bridge/postback/xyz" + }' +``` + +**Immediate Response (202 Accepted):** +```json +{ + "status": 202, + "code": "accepted", + "message": "Accepted" +} +``` + +**Immediate Response (409 Conflict - Version Out of Date):** +```json +{ + "status": 409, + "code": "outOfDate", + "message": "Out of Date" +} +``` + +**Postback Response (Success - sent to postbackUrl):** +```json +{ + "code": "upToDate", + "latestVerId": 244 +} +``` + +**Postback Response (Invalid Files):** +```json +{ + "code": "invalidFiles", + "errors": [ + { + "file": "invalid/../file.tex", + "state": "error" + } + ] +} +``` + +**Postback Response (Error):** +```json +{ + "code": "error", + "message": "Unexpected Error" +} +``` + +**How it works:** +1. Request is validated immediately +2. If latestVerId matches current version, returns 202 Accepted +3. Files are processed asynchronously: + - Downloads files from URLs if provided + - Creates/updates files in the project + - Deletes files not present in the new snapshot +4. Results are posted back to postbackUrl + +## Error Responses + +### 202 Accepted (POST endpoint) +```json +{ + "status": 202, + "code": "accepted", + "message": "Accepted" +} +``` + +### 404 Not Found +```json +{ + "message": "Project not found" +} +``` +or +```json +{ + "message": "Version not found" +} +``` + +### 403 Forbidden +```json +{ + "message": "Forbidden" +} +``` + +### 409 Conflict (POST endpoint - version mismatch) +```json +{ + "status": 409, + "code": "outOfDate", + "message": "Out of Date" +} +``` + +## Testing with Git Bridge + +1. **Configure Git Bridge:** + Update your git-bridge configuration to point to the web service: + ```json + { + "apiBaseUrl": "http://localhost:3000/api/v0/" + } + ``` + +2. **Clone a Project:** + ```bash + git clone http://git-bridge-host:8000/project_id + ``` + +3. **Make Changes and Push:** + ```bash + cd project_id + echo "new content" >> main.tex + git add main.tex + git commit -m "Update main.tex" + git push + ``` + +4. **Verify API Calls:** + Monitor the web service logs to verify API calls are being made correctly: + ```bash + tail -f logs/web.log | grep "api/v0" + ``` + +## Troubleshooting + +### "Project not found" error +- Verify the project ID is correct +- Ensure the user has read access to the project +- Check that the project exists in the database + +### "Forbidden" error +- Verify authentication credentials +- Ensure the user has appropriate permissions for the project +- Check OAuth2 configuration if using token-based auth + +### "Out of Date" error (409 Conflict) +- This occurs when the latestVerId in the push request doesn't match the current project version +- Solution: Pull latest changes from Overleaf before pushing +- Git-bridge handles this automatically by retrying the push + +### Empty response for saved versions +- This is normal if the project has no saved versions/labels +- Users need to manually create labels through the Overleaf UI + +### Push not completing +- Check the postback logs in git-bridge +- Verify the postbackUrl is accessible from the web service +- Check web service logs for errors during file processing + +### Binary file URLs not working +- Ensure the blob endpoint is accessible: `GET /project/:id/blob/:hash` +- Verify the history service is running +- Check file storage backend is accessible + +## Development + +### Adding Debug Logging + +To enable detailed logging for API v0 endpoints: + +```javascript +// In GitBridgeApiController.mjs +logger.debug({ projectId, version }, 'Getting snapshot') +``` + +### Testing Locally + +1. Start the web service in development mode +2. Create a test project with some history +3. Use curl or Postman to test endpoints manually +4. Check response formats match expected structure + +## Next Steps + +- Add comprehensive unit tests +- Add integration tests with actual git-bridge +- Implement rate limiting for git-bridge endpoints +- Add metrics and monitoring diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index e64f018d2f6..dfa5ac7b532 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -55,6 +55,7 @@ import LinkedFilesRouter from './Features/LinkedFiles/LinkedFilesRouter.mjs' import TemplatesRouter from './Features/Templates/TemplatesRouter.mjs' import UserMembershipRouter from './Features/UserMembership/UserMembershipRouter.mjs' import SystemMessageController from './Features/SystemMessages/SystemMessageController.mjs' +import GitBridgeApiController from './Features/GitBridge/GitBridgeApiController.mjs' import AnalyticsRegistrationSourceMiddleware from './Features/Analytics/AnalyticsRegistrationSourceMiddleware.mjs' import AnalyticsUTMTrackingMiddleware from './Features/Analytics/AnalyticsUTMTrackingMiddleware.mjs' import CaptchaMiddleware from './Features/Captcha/CaptchaMiddleware.mjs' @@ -1135,6 +1136,28 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { publicApiRouter.get('/health_check/mongo', HealthCheckController.checkMongo) privateApiRouter.get('/health_check/mongo', HealthCheckController.checkMongo) + // Git Bridge API v0 endpoints + publicApiRouter.get( + '/v0/docs/:project_id', + AuthorizationMiddleware.ensureUserCanReadProject, + GitBridgeApiController.getDoc + ) + publicApiRouter.get( + '/v0/docs/:project_id/saved_vers', + AuthorizationMiddleware.ensureUserCanReadProject, + GitBridgeApiController.getSavedVers + ) + publicApiRouter.get( + '/v0/docs/:project_id/snapshots/:version', + AuthorizationMiddleware.ensureUserCanReadProject, + GitBridgeApiController.getSnapshot + ) + publicApiRouter.post( + '/v0/docs/:project_id/snapshots', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + GitBridgeApiController.postSnapshot + ) + webRouter.get( '/status/compiler/:Project_id', RateLimiterMiddleware.rateLimit(rateLimiters.statusCompiler),