Skip to content

Commit 065dbc6

Browse files
committed
Merge PR #2: Implement API v0 endpoints for Git Bridge integration (GET endpoints)
2 parents 42a1816 + 3b2d5eb commit 065dbc6

File tree

4 files changed

+698
-0
lines changed

4 files changed

+698
-0
lines changed
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
// Controller for API v0 endpoints used by git-bridge
2+
// These endpoints provide git-bridge with access to project data, versions, and snapshots
3+
4+
import { callbackify } from 'node:util'
5+
import { expressify } from '@overleaf/promise-utils'
6+
import logger from '@overleaf/logger'
7+
import { fetchJson } from '@overleaf/fetch-utils'
8+
import settings from '@overleaf/settings'
9+
import ProjectGetter from '../Project/ProjectGetter.mjs'
10+
import HistoryManager from '../History/HistoryManager.mjs'
11+
import UserGetter from '../User/UserGetter.js'
12+
import { Snapshot } from 'overleaf-editor-core'
13+
import Errors from '../Errors/Errors.js'
14+
15+
/**
16+
* GET /api/v0/docs/:project_id
17+
* Returns the latest version info for a project
18+
*/
19+
async function getDoc(req, res, next) {
20+
const projectId = req.params.project_id
21+
22+
try {
23+
// Get project
24+
const project = await ProjectGetter.promises.getProject(projectId, {
25+
name: 1,
26+
owner_ref: 1,
27+
})
28+
29+
if (!project) {
30+
return res.status(404).json({ message: 'Project not found' })
31+
}
32+
33+
// Get latest history version
34+
const historyId = await HistoryManager.promises.getHistoryId(projectId)
35+
const latestHistory = await HistoryManager.promises.getLatestHistory(
36+
projectId
37+
)
38+
39+
if (!latestHistory || !latestHistory.updates) {
40+
// No history yet, return minimal response
41+
return res.json({
42+
latestVerId: 0,
43+
latestVerAt: new Date().toISOString(),
44+
latestVerBy: null,
45+
})
46+
}
47+
48+
// Get the most recent update
49+
const updates = latestHistory.updates
50+
const latestUpdate = updates[0] // updates are sorted newest first
51+
52+
let latestVerBy = null
53+
if (latestUpdate.meta && latestUpdate.meta.users) {
54+
const userId = latestUpdate.meta.users[0]
55+
if (userId) {
56+
const user = await UserGetter.promises.getUser(userId, {
57+
email: 1,
58+
first_name: 1,
59+
last_name: 1,
60+
})
61+
if (user) {
62+
const name = [user.first_name, user.last_name]
63+
.filter(Boolean)
64+
.join(' ')
65+
latestVerBy = {
66+
email: user.email,
67+
name: name || user.email,
68+
}
69+
}
70+
}
71+
}
72+
73+
const response = {
74+
latestVerId: latestUpdate.toV || 0,
75+
latestVerAt: latestUpdate.meta.end_ts
76+
? new Date(latestUpdate.meta.end_ts).toISOString()
77+
: new Date().toISOString(),
78+
latestVerBy,
79+
}
80+
81+
res.json(response)
82+
} catch (err) {
83+
logger.error({ err, projectId }, 'Error getting doc info')
84+
next(err)
85+
}
86+
}
87+
88+
/**
89+
* GET /api/v0/docs/:project_id/saved_vers
90+
* Returns the list of saved versions (labels) for a project
91+
*/
92+
async function getSavedVers(req, res, next) {
93+
const projectId = req.params.project_id
94+
95+
try {
96+
// Get project to verify it exists
97+
const project = await ProjectGetter.promises.getProject(projectId, {
98+
name: 1,
99+
})
100+
101+
if (!project) {
102+
return res.status(404).json({ message: 'Project not found' })
103+
}
104+
105+
// Get labels from project-history service
106+
let labels
107+
try {
108+
labels = await fetchJson(
109+
`${settings.apis.project_history.url}/project/${projectId}/labels`
110+
)
111+
} catch (err) {
112+
// If no labels exist, return empty array
113+
if (err.response?.status === 404) {
114+
labels = []
115+
} else {
116+
throw err
117+
}
118+
}
119+
120+
// Enrich labels with user information
121+
labels = await enrichLabels(labels)
122+
123+
// Transform to git-bridge format
124+
const savedVers = labels.map(label => ({
125+
versionId: label.version,
126+
comment: label.comment,
127+
user: {
128+
email: label.user_display_name || label.user?.email || 'unknown',
129+
name: label.user_display_name || label.user?.name || 'unknown',
130+
},
131+
createdAt: label.created_at,
132+
}))
133+
134+
res.json(savedVers)
135+
} catch (err) {
136+
logger.error({ err, projectId }, 'Error getting saved versions')
137+
next(err)
138+
}
139+
}
140+
141+
/**
142+
* GET /api/v0/docs/:project_id/snapshots/:version
143+
* Returns the snapshot (file contents) for a specific version
144+
*/
145+
async function getSnapshot(req, res, next) {
146+
const projectId = req.params.project_id
147+
const version = parseInt(req.params.version, 10)
148+
149+
try {
150+
// Get project to verify it exists
151+
const project = await ProjectGetter.promises.getProject(projectId, {
152+
name: 1,
153+
})
154+
155+
if (!project) {
156+
return res.status(404).json({ message: 'Project not found' })
157+
}
158+
159+
// Get snapshot content from history service
160+
const snapshotRaw = await HistoryManager.promises.getContentAtVersion(
161+
projectId,
162+
version
163+
)
164+
165+
const snapshot = Snapshot.fromRaw(snapshotRaw)
166+
167+
// Build response in git-bridge format
168+
// Note: srcs and atts are arrays of arrays: [[content, path], [content, path], ...]
169+
const srcs = []
170+
const atts = []
171+
172+
// Process all files in the snapshot
173+
const files = snapshot.getFileMap()
174+
for (const [pathname, file] of files) {
175+
if (file.isEditable()) {
176+
// Text file - include content directly as [content, path] array
177+
srcs.push([file.getContent(), pathname])
178+
} else {
179+
// Binary file - provide URL to download as [url, path] array
180+
const hash = file.getHash()
181+
182+
// Build URL to blob endpoint (already exists in web service)
183+
const blobUrl = `${settings.siteUrl}/project/${projectId}/blob/${hash}`
184+
185+
atts.push([blobUrl, pathname])
186+
}
187+
}
188+
189+
const response = {
190+
srcs,
191+
atts,
192+
}
193+
194+
res.json(response)
195+
} catch (err) {
196+
if (err instanceof Errors.NotFoundError) {
197+
return res.status(404).json({ message: 'Version not found' })
198+
}
199+
logger.error({ err, projectId, version }, 'Error getting snapshot')
200+
next(err)
201+
}
202+
}
203+
204+
/**
205+
* POST /api/v0/docs/:project_id/snapshots
206+
* Receives a push from git-bridge with file changes
207+
*/
208+
async function postSnapshot(req, res, next) {
209+
const projectId = req.params.project_id
210+
const { latestVerId, files, postbackUrl } = req.body
211+
212+
try {
213+
// Get project to verify it exists
214+
const project = await ProjectGetter.promises.getProject(projectId, {
215+
name: 1,
216+
})
217+
218+
if (!project) {
219+
return res.status(404).json({ message: 'Project not found' })
220+
}
221+
222+
// TODO: Implement snapshot push logic
223+
// This would involve:
224+
// 1. Validating the latestVerId matches current version
225+
// 2. Processing the files array (downloading from URLs if modified)
226+
// 3. Updating the project with new content
227+
// 4. Posting back results to postbackUrl
228+
229+
// For now, return "not implemented" response
230+
logger.warn(
231+
{ projectId, latestVerId },
232+
'Snapshot push not yet implemented'
233+
)
234+
235+
res.status(501).json({
236+
status: 501,
237+
code: 'notImplemented',
238+
message: 'Snapshot push not yet implemented',
239+
})
240+
} catch (err) {
241+
logger.error({ err, projectId }, 'Error posting snapshot')
242+
next(err)
243+
}
244+
}
245+
246+
/**
247+
* Enrich labels with user information
248+
*/
249+
async function enrichLabels(labels) {
250+
if (!labels || !labels.length) {
251+
return []
252+
}
253+
254+
// Get unique user IDs
255+
const uniqueUsers = new Set(labels.map(label => label.user_id))
256+
uniqueUsers.delete(null)
257+
uniqueUsers.delete(undefined)
258+
259+
// Fetch user details
260+
const userDetailsMap = new Map()
261+
for (const userId of uniqueUsers) {
262+
try {
263+
const user = await UserGetter.promises.getUser(userId, {
264+
email: 1,
265+
first_name: 1,
266+
last_name: 1,
267+
})
268+
if (user) {
269+
const name = [user.first_name, user.last_name]
270+
.filter(Boolean)
271+
.join(' ')
272+
userDetailsMap.set(userId.toString(), {
273+
email: user.email,
274+
name: name || user.email,
275+
})
276+
}
277+
} catch (err) {
278+
logger.warn({ err, userId }, 'Failed to get user details for label')
279+
}
280+
}
281+
282+
// Enrich labels
283+
return labels.map(label => {
284+
const enrichedLabel = { ...label }
285+
if (label.user_id) {
286+
const userDetails = userDetailsMap.get(label.user_id.toString())
287+
if (userDetails) {
288+
enrichedLabel.user = userDetails
289+
enrichedLabel.user_display_name = userDetails.name
290+
}
291+
}
292+
return enrichedLabel
293+
})
294+
}
295+
296+
export default {
297+
getDoc: expressify(getDoc),
298+
getSavedVers: expressify(getSavedVers),
299+
getSnapshot: expressify(getSnapshot),
300+
postSnapshot: expressify(postSnapshot),
301+
}

0 commit comments

Comments
 (0)