token-api: review comments API authenticated by git-bridge tokens (#273)

* document-updater: HTTP route to add a comment range to a doc

POST /project/:project_id/doc/:doc_id/comment applies a comment op to the
doc in document-updater (as the real-time path does), so web modules can
create anchored review comments without a websocket connection.

* token-api: review comments API authenticated by git-bridge tokens

New module token-api under /api/v1/project/:project_id for scripts and
agents: list comment threads and tracked changes (JSON or Markdown, with
file, line and quote), create anchored comments, reply, resolve and
reopen. Authenticates with the git-bridge personal access tokens (Bearer).

Permissions follow the editor's review routes (as tightened in #271):
non-members and restricted link-sharing users get 404, read-only
members may read and comment, resolve/reopen is allowed for owners and
editors on any thread and for reviewers on threads they started. Site
admins get no special access. Requests are audit-logged.

The module README is served at GET /api/v1. The git integration widget
notes that tokens also authenticate this API.
This commit is contained in:
Tobias Hallmen
2026-11-15 04:45:11 +01:00
committed by GitHub
parent b8a622a390
commit d796ccb39d
13 changed files with 1303 additions and 0 deletions
+1
View File
@@ -161,6 +161,7 @@ app.post(
'/project/:project_id/doc/:doc_id/change/accept',
HttpController.acceptChanges
)
app.post('/project/:project_id/doc/:doc_id/comment', HttpController.addComment)
app.post(
'/project/:project_id/doc/:doc_id/comment/:comment_id/resolve',
HttpController.resolveComment
@@ -457,6 +457,62 @@ const DocumentManager = {
}
},
async addComment(
projectId,
docId,
threadId,
userId,
text,
offset,
expectedVersion
) {
// Circular dependencies. Import at runtime.
const UpdateManager = require('./UpdateManager')
const { lines, version, ranges, type } = await DocumentManager.getDoc(
projectId,
docId
)
if (lines == null || version == null) {
throw new Errors.NotFoundError(`document not found: ${docId}`)
}
if (type !== 'sharejs-text-ot') {
throw new Errors.OTTypeMismatchError(type, 'sharejs-text-ot')
}
// Everything below is checked here, under the lock, because a rejection
// inside applyUpdate happens after ShareJS has broadcast the op and
// disconnects every editor on the doc.
if (expectedVersion != null && expectedVersion !== version) {
throw new Errors.DocumentValidationError('document changed', {
docId,
expectedVersion,
version,
})
}
if ((ranges?.comments?.length ?? 0) >= RangesManager.MAX_COMMENTS) {
throw new Errors.DocumentValidationError('too many comments', { docId })
}
const docText = lines.join('\n')
if (docText.slice(offset, offset + text.length) !== text) {
throw new Errors.DocumentValidationError(
'comment text does not match document at offset',
{ docId, offset }
)
}
// No meta.type 'external': that would show every editor the "document
// updated externally" modal. The op is broadcast to all clients anyway.
const update = {
doc: docId,
op: [{ c: text, p: offset, t: threadId }],
v: version,
meta: { user_id: userId, source: 'add-comment' },
}
await UpdateManager.promises.applyUpdate(projectId, docId, update)
// Only ranges changed; the usual flushing persists them.
},
async getComment(projectId, docId, commentId) {
let { lines, ranges, type } = await DocumentManager.getDoc(projectId, docId)
@@ -749,6 +805,28 @@ const DocumentManager = {
)
},
async addCommentWithLock(
projectId,
docId,
threadId,
userId,
text,
offset,
expectedVersion
) {
const UpdateManager = require('./UpdateManager')
await UpdateManager.promises.lockUpdatesAndDo(
DocumentManager.addComment,
projectId,
docId,
threadId,
userId,
text,
offset,
expectedVersion
)
},
async deleteCommentWithLock(projectId, docId, threadId, userId) {
const UpdateManager = require('./UpdateManager')
await UpdateManager.promises.lockUpdatesAndDo(
@@ -534,6 +534,35 @@ const commentWithUserSchema = z.object({
}),
})
const addCommentSchema = z.object({
params: docParamsSchema,
body: z.strictObject({
thread_id: zz.objectId(),
user_id: zz.objectId(),
text: z.string().min(1),
offset: z.number().int().nonnegative(),
version: z.number().int().nonnegative().optional(),
}),
})
async function addComment(req, res) {
const { params, body } = parseReq(req, addCommentSchema)
const { project_id: projectId, doc_id: docId } = params
const { thread_id: threadId, user_id: userId, text, offset, version } = body
logger.debug({ projectId, docId, threadId }, 'adding comment via http')
await DocumentManager.promises.addCommentWithLock(
projectId,
docId,
threadId,
userId,
text,
offset,
version
)
logger.debug({ projectId, docId, threadId }, 'added comment via http')
res.sendStatus(204) // No Content
}
async function resolveComment(req, res) {
const { params, body } = parseReq(req, commentWithUserSchema, {
logOnly: true,
@@ -868,6 +897,7 @@ module.exports = {
deleteProject: expressify(deleteProject),
deleteMultipleProjects: expressify(deleteMultipleProjects),
acceptChanges: expressify(acceptChanges),
addComment: expressify(addComment),
resolveComment: expressify(resolveComment),
reopenComment: expressify(reopenComment),
deleteComment: expressify(deleteComment),
+1
View File
@@ -1259,6 +1259,7 @@ module.exports = {
'instance-stats',
'template-gallery',
'git-bridge',
'token-api',
'git-sync/github',
'git-sync/gitlab',
'git-sync/gitea',
@@ -2778,6 +2778,7 @@
"your_git_access_info_bullet_3": "",
"your_git_access_info_bullet_4": "",
"your_git_access_info_bullet_5": "",
"your_git_access_info_review_comments_api": "",
"your_git_access_tokens": "",
"your_limit_will_reset_in_time_or_speak_to_admin": "",
"your_message_to_collaborators": "",
+1
View File
@@ -3498,6 +3498,7 @@
"your_git_access_info_bullet_3": "You can generate a token using the <0>Generate token</0> button.",
"your_git_access_info_bullet_4": "You won’t be able to view the full token after the first time you generate it. Please copy it and keep it safe",
"your_git_access_info_bullet_5": "Previously generated tokens will be shown here.",
"your_git_access_info_review_comments_api": "The same tokens also authenticate the review comments API of your projects, documented at /api/v1.",
"your_git_access_tokens": "Your Git authentication tokens",
"your_limit_will_reset_in_time_or_speak_to_admin": "Your limit will reset in __time__. Or speak to your group admin about upgrading.",
"your_message_to_collaborators": "Send a message to your collaborators",
@@ -100,6 +100,10 @@ export const GitIntegrationWidget = function GitIntegrationWidget() {
)}
</ul>
<p className="small">
{t('your_git_access_info_review_comments_api')}
</p>
<TokenTable
tokens={tokens}
onCreateToken={handleCreateToken}
+193
View File
@@ -0,0 +1,193 @@
# token-api
Read and write review comments of a project over HTTP, authenticated with a
git-bridge personal access token. Meant for scripts and coding agents that
already work on the project through the git bridge and need to see the
comments reviewers left in the editor, answer them, resolve them, or leave
their own.
Enabled together with the git bridge (`GIT_BRIDGE_ENABLED=true`); without it
the module does nothing. This file is served as `GET /api/v1` (Markdown, no
token needed), so a client can be pointed at that URL.
## Authentication
Every request carries a git-bridge token:
Authorization: Bearer olp_...
Tokens are created in Account Settings → Git integration. The same token that
authenticates `git clone` works here; if a git credential helper already
stores it, it can be reused from there.
The token identifies a user. The user must be a member of the project (owner,
editor, reviewer or read-only). Requests for projects the user is not a member
of, and for ids that do not exist, answer `404` so the API does not reveal
which projects exist. Site admins get no special access. Rate limit: 60
requests per minute per client IP (`429`).
### Permissions
The rules follow the editor's review routes:
| Route | Owner, editor (also editor via sharing link) | Reviewer | Read-only (invited) | Read-only via sharing link |
| --- | --- | --- | --- | --- |
| `GET /threads`, `GET /ranges` | yes | yes | yes | `404` |
| `POST /comments` | yes | yes | yes | `404` |
| `POST /thread/:thread_id/messages` | yes | yes | yes | `404` |
| `POST .../resolve`, `.../reopen` | yes | own threads only, else `403` | `403` | `404` |
A read-only member who joined through a sharing link and was never invited
does not see comments in the editor, so the API treats them as a non-member.
A reviewer may resolve or reopen a thread whose first message they wrote.
`403` answers `{ "error": "forbidden" }`.
Comments created or answered through the API appear in the editor under the
token user's name, like comments made in the browser.
## Routes
All routes live under `/api/v1/project/:project_id`. Ids are 24-hex Mongo ids.
Bodies are JSON.
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/threads` | All comment threads and tracked changes, with file and line |
| GET | `/threads?format=md` | Same as Markdown, for pasting into a prompt |
| POST | `/comments` | New comment anchored to a text passage |
| POST | `/thread/:thread_id/messages` | Reply to a thread |
| POST | `/doc/:doc_id/thread/:thread_id/resolve` | Resolve a thread |
| POST | `/doc/:doc_id/thread/:thread_id/reopen` | Reopen a thread |
| GET | `/ranges` | Raw `ranges` object per doc (debugging) |
`GET /api/v1` (without a project) returns this document.
### GET /threads
```json
{
"project": { "id": "…", "name": "My paper" },
"generated_at": "2026-10-10T14:00:00.000Z",
"threads": [
{
"id": "<thread_id>",
"doc_id": "<doc_id>",
"file": "sections/intro.tex",
"line": 42,
"quote": "the text the comment is attached to",
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"messages": [
{
"id": "…",
"author": { "id": "…", "name": "Jane Doe" },
"time": "2026-10-10T13:58:00.000Z",
"edited_at": null,
"content": "Please cite the 2024 survey here."
}
]
}
],
"changes": [
{
"id": "…",
"file": "sections/intro.tex",
"line": 7,
"type": "insert",
"text": "inserted text",
"author": { "id": "…", "name": "Jane Doe" },
"time": "2026-10-10T13:50:00.000Z"
}
]
}
```
Threads are sorted by file and line. A thread whose anchor was deleted from
the document has `doc_id`, `file`, `line` and `quote` set to `null`. `line`
counts from 1 and refers to the current document content.
The Markdown form prints one heading per file and one `### Line N (open|resolved)
[thread <id>]` heading per thread, followed by the messages, then a
"Tracked changes" section.
### POST /comments
Create a thread anchored to a passage. The document is read live; the
passage must exist exactly once, or `offset` must name its position.
Request:
```json
{ "file": "sections/intro.tex", "quote": "the text to comment on", "content": "The comment." }
```
Optional `offset`: UTF-16 code-unit offset of `quote` in the document, for a
passage that occurs more than once, counted in the document text with lines
joined by `\n`.
Response `201`:
```json
{
"thread_id": "…", "doc_id": "…", "file": "sections/intro.tex", "line": 42,
"quote": "the text to comment on",
"message": { "id": "…", "time": "…", "content": "The comment." }
}
```
Errors: `400 bad request` (missing fields, `quote` splits a surrogate pair),
`404` (unknown file), `409 conflict` with `reason` one of `quote not found`,
`quote ambiguous`, `quote not at offset`, `anchor rejected` (document changed
between read and write, or the per-document comment limit is reached). On
`409` nothing was written; read `/threads` again and retry.
### POST /thread/:thread_id/messages
Request `{ "content": "Reply text." }`. Response `201`
`{ "id", "time", "content" }`. `404` for a thread the project does not have.
### POST /doc/:doc_id/thread/:thread_id/resolve, /reopen
No body. Response `204`. `404` unless the thread is anchored in that
document (`doc_id` from `/threads`).
## Example
```sh
TOKEN=olp_...
P=https://overleaf.example.org/api/v1/project/<project_id>
curl -sS -H "Authorization: Bearer $TOKEN" "$P/threads?format=md"
curl -sS -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"content":"Done, see commit abc123."}' "$P/thread/<thread_id>/messages"
curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
"$P/doc/<doc_id>/thread/<thread_id>/resolve"
```
Avoid putting the token on the command line on shared machines; curl reads
`-H` lines from a config file given with `--config`.
## Implementation notes
- `TokenApiAuthMiddleware.mjs` validates the token through
`GitBridgePATManager.getUserId`, checks membership with
`AuthorizationManager.getPrivilegeLevelForProject` (no admin shortcut, no
public access), rejects restricted users (`AuthorizationManager.isRestrictedUser`)
and logs one line per authenticated request. Resolve and reopen add
`tokenThreadResolveAccess`, which applies the rule of core's
`AuthorizationManager.canUserDeleteOrResolveThread` to the admin-free
privilege level.
- `GET /threads` flushes the project to Mongo, then combines docstore ranges
(anchors), chat threads (messages) and doc paths. `op.p` of a comment is a
UTF-16 offset into `lines.join('\n')`.
- Writes go through the same handlers the editor uses (`ChatApiHandler`,
`DocumentUpdaterHandler`) and emit the same real-time events, so open
editors update without reload.
- `POST /comments` uses a document-updater route,
`POST /project/:project_id/doc/:doc_id/comment`, which applies the comment
op under the document lock after checking version, text at offset and the
comment limit, so a rejected anchor never reaches connected editors.
- Routes are on `publicApiRouter`: no session, no CSRF.
@@ -0,0 +1,160 @@
import logger from '@overleaf/logger'
import AuthorizationManager from '../../../../app/src/Features/Authorization/AuthorizationManager.mjs'
import ChatApiHandler from '../../../../app/src/Features/Chat/ChatApiHandler.mjs'
import CollaboratorsGetter from '../../../../app/src/Features/Collaborators/CollaboratorsGetter.mjs'
import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import {
InvalidParamsError,
parseReq,
z,
} from '../../../../app/src/infrastructure/Validation.mjs'
import GitBridgePATManager from '../../../git-bridge/app/src/GitBridgePATManager.mjs'
export const ID_REGEX = /^[0-9a-f]{24}$/
// Not zz.objectId(): ObjectId.isValid also accepts upper-case hex and any
// 12-character string.
export const objectId = z.string().regex(ID_REGEX)
const BEARER = /^Bearer\s+(\S+)$/i
// Members only: no site-admin shortcut, no legacy public access.
const MEMBER_LEVELS = [
PrivilegeLevels.OWNER,
PrivilegeLevels.READ_AND_WRITE,
PrivilegeLevels.READ_ONLY,
PrivilegeLevels.REVIEW,
]
function unauthorized(req, res, reason) {
logger.info({ ip: req.ip, path: req.path, reason }, 'token-api: unauthorized')
res.status(401).json({ error: 'unauthorized' })
}
export function notFound(res) {
res.status(404).json({ error: 'not found' })
}
// project_id on every route; doc_id and thread_id where the path has them.
const idsSchema = z.object({
params: z.object({
project_id: objectId,
doc_id: objectId.optional(),
thread_id: objectId.optional(),
}),
})
export default async function tokenProjectAccess(req, res, next) {
try {
res.set('Cache-Control', 'no-store')
const match = BEARER.exec((req.headers.authorization || '').trim())
if (!match) {
return unauthorized(req, res, 'missing or malformed authorization header')
}
const userId = await GitBridgePATManager.getUserId(match[1])
if (!userId) return unauthorized(req, res, 'invalid token')
let projectId
// One audit line per authenticated request, including 404s and aborted
// connections ('close' fires in both cases, 'finish' only on success).
res.on('close', () => {
logger.info(
{
userId,
projectId,
method: req.method,
path: req.path,
status: res.writableFinished ? res.statusCode : 'aborted',
},
'token-api request'
)
})
// Malformed ids answer 404 before the membership check sees them.
try {
projectId = parseReq(req, idsSchema).params.project_id
} catch (err) {
if (err instanceof InvalidParamsError) return notFound(res)
throw err
}
let level
try {
level = await AuthorizationManager.promises.getPrivilegeLevelForProject(
userId,
projectId,
null,
{ ignoreSiteAdmin: true, ignorePublicAccess: true }
)
} catch (err) {
if (err instanceof Errors.NotFoundError) return notFound(res)
throw err
}
if (!MEMBER_LEVELS.includes(level)) return notFound(res)
// Restricted users (read-only via a sharing link, not invited) see no
// comments in the editor and the review routes block them
// (blockRestrictedUserFromProject). Here they are treated as non-members.
if (
level === PrivilegeLevels.READ_ONLY &&
AuthorizationManager.isRestrictedUser(
userId,
level,
await CollaboratorsGetter.promises.userIsTokenMember(userId, projectId),
await CollaboratorsGetter.promises.isUserInvitedMemberOfProject(
userId,
projectId
)
)
) {
return notFound(res)
}
// A string, as in a session: canResolveThread compares it with the
// user_id strings that chat returns.
req.tokenUserId = userId.toString()
req.tokenPrivilegeLevel = level
next()
} catch (err) {
next(err)
}
}
// Resolve and reopen, after tokenProjectAccess. Mirrors core's
// AuthorizationManager.canUserDeleteOrResolveThread (behind the editor's
// ensureUserCanDeleteOrResolveThread): owners and editors on any thread,
// reviewers only on threads whose first message they wrote; a missing thread
// or any chat error denies. Not called directly because core does not ignore
// site admins there, and the level here was computed with ignoreSiteAdmin.
async function canResolveThread(level, userId, projectId, threadId) {
if (
level === PrivilegeLevels.OWNER ||
level === PrivilegeLevels.READ_AND_WRITE
) {
return true
}
if (level !== PrivilegeLevels.REVIEW) return false
try {
const thread = await ChatApiHandler.promises.getThread(projectId, threadId)
return thread.messages.length > 0 && thread.messages[0].user_id === userId
} catch (err) {
return false
}
}
export async function tokenThreadResolveAccess(req, res, next) {
try {
const { project_id: projectId, thread_id: threadId } = parseReq(
req,
idsSchema
).params
const allowed = await canResolveThread(
req.tokenPrivilegeLevel,
req.tokenUserId,
projectId,
threadId
)
if (!allowed) return res.status(403).json({ error: 'forbidden' })
next()
} catch (err) {
next(err)
}
}
@@ -0,0 +1,545 @@
import { readFileSync } from 'node:fs'
import { fetchNothing, RequestFailedError } from '@overleaf/fetch-utils'
import logger from '@overleaf/logger'
import settings from '@overleaf/settings'
import { ObjectId } from 'mongodb'
import ChatApiHandler from '../../../../app/src/Features/Chat/ChatApiHandler.mjs'
import DocstoreManager from '../../../../app/src/Features/Docstore/DocstoreManager.mjs'
import DocumentUpdaterHandler from '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
import EditorRealTimeController from '../../../../app/src/Features/Editor/EditorRealTimeController.mjs'
import ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs'
import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs'
import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs'
import UserInfoController from '../../../../app/src/Features/User/UserInfoController.mjs'
import UserInfoManager from '../../../../app/src/Features/User/UserInfoManager.mjs'
import {
InvalidRequestError,
parseReq,
z,
} from '../../../../app/src/infrastructure/Validation.mjs'
import { ID_REGEX, notFound, objectId } from './TokenApiAuthMiddleware.mjs'
// The module README doubles as the API documentation, served at GET /api/v1.
let README = null
try {
README = readFileSync(new URL('../../README.md', import.meta.url), 'utf8')
} catch (err) {
logger.warn(
{ err },
'token-api: README.md not found, GET /api/v1 answers 404'
)
}
const MAX_MESSAGE_LENGTH = 10 * 1024 // chat's limit
const MAX_QUOTE_LENGTH = 10 * 1024
// A bad request body, or a 404 (unknown thread, comment not in that doc) or
// 400 from chat or document-updater, becomes the API's fixed body; anything
// else is a 500.
function handleWriteError(err, res, next) {
if (err instanceof InvalidRequestError) {
return res.status(400).json({ error: 'bad request' })
}
if (err instanceof RequestFailedError) {
if (err.response.status === 404) return notFound(res)
if (err.response.status === 400) {
return res.status(400).json({ error: 'bad request' })
}
}
next(err)
}
function iso(value) {
if (value == null) return null
const date = new Date(value)
return isNaN(date) ? null : date.toISOString()
}
// '2026-10-10T12:34:56.789Z' -> '2026-10-10 12:34'
function shortTime(isoString) {
return isoString ? isoString.slice(0, 16).replace('T', ' ') : '?'
}
// op.p counts UTF-16 code units of lines.join('\n'), as does String.slice.
function lineAt(text, offset) {
return text.slice(0, offset).split('\n').length
}
// First two occurrences of quote in text: enough to tell unique from ambiguous.
function findOffsets(text, quote) {
const first = text.indexOf(quote)
if (first === -1) return []
const second = text.indexOf(quote, first + 1)
return second === -1 ? [first] : [first, second]
}
// UTF-16 offsets (what ShareJS counts): true if i falls between the halves of a surrogate pair.
function splitsSurrogatePair(text, i) {
return (
/[\uD800-\uDBFF]/.test(text[i - 1] ?? '') &&
/[\uDC00-\uDFFF]/.test(text[i] ?? '')
)
}
function byFileLine(a, b) {
if (a.file !== b.file) {
if (a.file == null) return 1
if (b.file == null) return -1
return a.file < b.file ? -1 : 1
}
if (a.line !== b.line) {
if (a.line == null) return 1
if (b.line == null) return -1
return a.line - b.line
}
return 0
}
async function getAuthors(userIds) {
const ids = [...new Set(userIds.filter(id => id && ID_REGEX.test(id)))]
const users = await UserGetter.promises.getUsers(ids, {
first_name: 1,
last_name: 1,
})
const names = new Map()
for (const user of users) {
const name = `${user.first_name || ''} ${user.last_name || ''}`.trim()
names.set(user._id.toString(), name || null)
}
return id => (id ? { id, name: names.get(id) ?? null } : null)
}
function toMarkdown(view) {
const out = [
`# ${view.project.name}: review comments (${shortTime(view.generated_at)} UTC)`,
]
let currentFile
for (const thread of view.threads) {
if (thread.file !== currentFile) {
currentFile = thread.file
out.push('', `## ${thread.file ?? '(no file)'}`)
}
const state = thread.resolved ? 'resolved' : 'open'
const where = thread.line != null ? `Line ${thread.line}` : 'Thread'
out.push('', `### ${where} (${state}) [thread ${thread.id}]`)
if (thread.quote != null) {
out.push(...thread.quote.split('\n').map(line => `> ${line}`))
}
if (thread.messages.length > 0) out.push('')
for (const message of thread.messages) {
const name = message.author?.name ?? message.author?.id ?? 'unknown'
out.push(
`- **${name}**, ${shortTime(message.time)}: ${message.content.replaceAll('\n', '\n ')}`
)
}
}
if (view.changes.length > 0) {
out.push('', '## Tracked changes')
for (const change of view.changes) {
const name = change.author?.name ?? change.author?.id ?? 'unknown'
const where = `${change.file ?? '(no file)'}:${change.line ?? '?'}`
// JSON string literal: newlines and backticks cannot break the list item
out.push(
`- ${where} ${change.type} by ${name}, ${shortTime(change.time)}: ${JSON.stringify(change.text)}`
)
}
}
return out.join('\n') + '\n'
}
// Ids are already checked by the middleware (malformed -> 404).
const projectSchema = z.object({
params: z.object({ project_id: objectId }),
})
const getThreadsSchema = z.object({
params: z.object({ project_id: objectId }),
// Any other format (or a repeated one) falls back to JSON, never 400.
query: z.object({ format: z.literal('md').optional().catch(undefined) }),
})
// Content rules as in chat: not blank, at most chat's length limit.
const contentSchema = z
.string()
.max(MAX_MESSAGE_LENGTH)
.refine(s => s.trim() !== '')
const sendMessageSchema = z.object({
params: z.object({ project_id: objectId, thread_id: objectId }),
body: z.object({ content: contentSchema }),
})
const createCommentSchema = z.object({
params: z.object({ project_id: objectId }),
body: z.object({
file: z.string().min(1),
quote: z.string().min(1).max(MAX_QUOTE_LENGTH),
content: contentSchema,
// Number.isInteger, not .int(): .int() also caps at MAX_SAFE_INTEGER.
offset: z.number().min(0).refine(Number.isInteger).optional(),
}),
})
const docThreadSchema = z.object({
params: z.object({
project_id: objectId,
doc_id: objectId,
thread_id: objectId,
}),
})
const TokenApiController = {
docs(req, res) {
if (README == null) return notFound(res)
res.type('text/markdown; charset=utf-8').send(README)
},
async getRanges(req, res, next) {
try {
const { project_id } = parseReq(req, projectSchema).params
// Flush unflushed edits to docstore (docs stay loaded), then read all docs from there.
await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id)
const docs = await DocstoreManager.promises.getAllRanges(project_id)
res.json(docs.map(doc => ({ id: doc._id, ranges: doc.ranges ?? {} })))
} catch (err) {
next(err)
}
},
async getThreads(req, res, next) {
try {
const { params, query } = parseReq(req, getThreadsSchema)
const { project_id } = params
await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id)
// lines and ranges from one docstore read, so offsets match the text
const [project, paths, docs, chatThreads] = await Promise.all([
ProjectGetter.promises.getProject(project_id, { name: 1 }),
ProjectEntityHandler.promises.getAllDocPathsFromProjectById(project_id),
DocstoreManager.promises.getAllDocsWithRanges(project_id),
ChatApiHandler.promises.getThreads(project_id),
])
const anchors = new Map()
const rawChanges = []
for (const doc of docs) {
const docId = doc._id.toString()
const file = paths[docId]?.replace(/^\//, '') ?? null
const text = (doc.lines ?? []).join('\n')
const locate = p => ({
file,
line: file != null ? lineAt(text, p) : null,
})
for (const comment of doc.ranges?.comments ?? []) {
anchors.set(String(comment.op.t), {
doc_id: docId,
...locate(comment.op.p),
quote: comment.op.c,
})
}
for (const change of doc.ranges?.changes ?? []) {
rawChanges.push({ change, ...locate(change.op.p) })
}
}
const userIds = []
for (const thread of Object.values(chatThreads)) {
userIds.push(thread.resolved_by_user_id)
for (const message of thread.messages ?? [])
userIds.push(message.user_id)
}
for (const { change } of rawChanges)
userIds.push(change.metadata?.user_id)
const author = await getAuthors(
userIds.map(id => (id ? String(id) : null))
)
const threadIds = new Set([
...Object.keys(chatThreads),
...anchors.keys(),
])
const threads = [...threadIds].map(id => {
const chat = chatThreads[id] ?? {}
const anchor = anchors.get(id) ?? {
doc_id: null,
file: null,
line: null,
quote: null,
}
return {
id,
doc_id: anchor.doc_id,
file: anchor.file,
line: anchor.line,
quote: anchor.quote,
resolved: !!chat.resolved,
resolved_at: iso(chat.resolved_at),
resolved_by: chat.resolved_by_user_id
? author(String(chat.resolved_by_user_id))
: null,
messages: (chat.messages ?? []).map(m => ({
id: m.id,
author: m.user_id ? author(String(m.user_id)) : null,
time: iso(m.timestamp),
edited_at: iso(m.edited_at),
content: m.content,
})),
}
})
const firstTime = t => t.messages[0]?.time ?? ''
threads.sort(
(a, b) => byFileLine(a, b) || firstTime(a).localeCompare(firstTime(b))
)
const changes = rawChanges.map(({ change, file, line }) => ({
id: change.id,
file,
line,
type: change.op.i != null ? 'insert' : 'delete',
text: change.op.i ?? change.op.d,
author: change.metadata?.user_id
? author(String(change.metadata.user_id))
: null,
time: iso(change.metadata?.ts),
}))
changes.sort(byFileLine)
const view = {
project: { id: project_id, name: project?.name ?? null },
generated_at: new Date().toISOString(),
threads,
changes,
}
if (query.format === 'md') {
res.type('text/markdown; charset=utf-8').send(toMarkdown(view))
} else {
res.json(view)
}
} catch (err) {
next(err)
}
},
async sendMessage(req, res, next) {
try {
const { params, body } = parseReq(req, sendMessageSchema)
const { project_id, thread_id } = params
const { content } = body
// Reading a thread does not create it; sending to one does. Reject
// unknown IDs instead of creating an orphan thread without an anchor.
await ChatApiHandler.promises.getThread(project_id, thread_id)
const userId = req.tokenUserId
const user = await UserInfoManager.promises.getPersonalInfo(userId)
const message = await ChatApiHandler.promises.sendComment(
project_id,
thread_id,
userId,
content
)
message.user = UserInfoController.formatPersonalInfo(user)
EditorRealTimeController.emitToRoom(
project_id,
'new-comment',
thread_id,
message
)
res.status(201).json({
id: message.id,
time: iso(message.timestamp),
content: message.content,
})
} catch (err) {
handleWriteError(err, res, next)
}
},
async createComment(req, res, next) {
try {
const { params, body } = parseReq(req, createCommentSchema)
const { project_id } = params
const { file, quote, content, offset } = body
const wanted = file.replace(/^\//, '')
const paths =
await ProjectEntityHandler.promises.getAllDocPathsFromProjectById(
project_id
)
const docId = Object.keys(paths).find(
id => paths[id].replace(/^\//, '') === wanted
)
if (!docId) return notFound(res)
const { lines, version } =
await DocumentUpdaterHandler.promises.getDocument(project_id, docId, -1)
const text = lines.join('\n')
let at = offset
if (at !== undefined) {
if (text.slice(at, at + quote.length) !== quote) {
return res
.status(409)
.json({ error: 'conflict', reason: 'quote not at offset' })
}
} else {
const offsets = findOffsets(text, quote)
if (offsets.length === 0) {
return res
.status(409)
.json({ error: 'conflict', reason: 'quote not found' })
}
if (offsets.length > 1) {
return res
.status(409)
.json({ error: 'conflict', reason: 'quote ambiguous' })
}
at = offsets[0]
}
if (
splitsSurrogatePair(text, at) ||
splitsSurrogatePair(text, at + quote.length)
) {
return res.status(400).json({ error: 'bad request' })
}
const line = lineAt(text, at)
const threadId = new ObjectId().toString()
const userId = req.tokenUserId
const user = await UserInfoManager.promises.getPersonalInfo(userId)
// Creates the thread; the anchor below makes it visible in the doc.
const message = await ChatApiHandler.promises.sendComment(
project_id,
threadId,
userId,
content
)
// Editors learn the thread before the op arrives, as with the editor's own flow.
message.user = UserInfoController.formatPersonalInfo(user)
EditorRealTimeController.emitToRoom(
project_id,
'new-comment',
threadId,
message
)
try {
// All three IDs are 24-hex: project_id checked by the middleware,
// docId from the project, threadId generated above.
await fetchNothing(
`${settings.apis.documentupdater.url}/project/${project_id}/doc/${docId}/comment`,
{
method: 'POST',
json: {
thread_id: threadId,
user_id: userId,
text: quote,
offset: at,
version,
},
signal: AbortSignal.timeout(30 * 1000),
}
)
} catch (err) {
// Roll back both sides. After a timeout the outcome is unknown, so the
// range is removed too (a no-op if it was never applied).
const warn = (e, what) =>
logger.warn(
{ err: e, projectId: project_id, docId, threadId },
`token-api: ${what} after failed anchor`
)
await DocumentUpdaterHandler.promises
.deleteThread(project_id, docId, threadId, userId)
.catch(e => warn(e, 'could not remove range'))
await ChatApiHandler.promises
.deleteThread(project_id, threadId)
.catch(e => warn(e, 'could not delete thread'))
EditorRealTimeController.emitToRoom(
project_id,
'delete-thread',
threadId
)
if (err instanceof RequestFailedError) {
// 422: text or version changed, too many comments, or not a ShareJS doc
if (err.response.status === 422) {
return res
.status(409)
.json({ error: 'conflict', reason: 'anchor rejected' })
}
if (err.response.status === 404) return notFound(res)
}
return next(err)
}
res.status(201).json({
thread_id: threadId,
doc_id: docId,
file: paths[docId].replace(/^\//, ''),
line,
quote,
message: {
id: message.id,
time: iso(message.timestamp),
content: message.content,
},
})
} catch (err) {
handleWriteError(err, res, next)
}
},
async resolveThread(req, res, next) {
try {
const { params } = parseReq(req, docThreadSchema)
const { project_id, doc_id, thread_id } = params
// 404 unless the comment is anchored in this doc of this project; the
// chat write below must not happen for a wrong doc_id.
await DocumentUpdaterHandler.promises.getComment(
project_id,
doc_id,
thread_id
)
const userId = req.tokenUserId
const user = await UserInfoManager.promises.getPersonalInfo(userId)
await ChatApiHandler.promises.resolveThread(project_id, thread_id, userId)
EditorRealTimeController.emitToRoom(
project_id,
'resolve-thread',
thread_id,
UserInfoController.formatPersonalInfo(user)
)
await DocumentUpdaterHandler.promises.resolveThread(
project_id,
doc_id,
thread_id,
userId
)
res.sendStatus(204)
} catch (err) {
handleWriteError(err, res, next)
}
},
async reopenThread(req, res, next) {
try {
const { params } = parseReq(req, docThreadSchema)
const { project_id, doc_id, thread_id } = params
await DocumentUpdaterHandler.promises.getComment(
project_id,
doc_id,
thread_id
)
await ChatApiHandler.promises.reopenThread(project_id, thread_id)
EditorRealTimeController.emitToRoom(
project_id,
'reopen-thread',
thread_id
)
await DocumentUpdaterHandler.promises.reopenThread(
project_id,
doc_id,
thread_id,
req.tokenUserId
)
res.sendStatus(204)
} catch (err) {
handleWriteError(err, res, next)
}
},
}
export default TokenApiController
@@ -0,0 +1,59 @@
import logger from '@overleaf/logger'
import RateLimiterMiddleware from '../../../../app/src/Features/Security/RateLimiterMiddleware.mjs'
import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.mjs'
import tokenProjectAccess, {
tokenThreadResolveAccess,
} from './TokenApiAuthMiddleware.mjs'
import TokenApiController from './TokenApiController.mjs'
const rateLimiter = new RateLimiter('token-api', { points: 60, duration: 60 })
export default {
apply(webRouter, privateApiRouter, publicApiRouter) {
logger.debug({}, 'Init token-api router')
const guard = [
RateLimiterMiddleware.rateLimit(rateLimiter, { ipOnly: true }),
tokenProjectAccess,
]
// API documentation (this module's README); no token needed.
publicApiRouter.get(
'/api/v1',
RateLimiterMiddleware.rateLimit(rateLimiter, { ipOnly: true }),
TokenApiController.docs
)
publicApiRouter.get(
'/api/v1/project/:project_id/ranges',
...guard,
TokenApiController.getRanges
)
publicApiRouter.get(
'/api/v1/project/:project_id/threads',
...guard,
TokenApiController.getThreads
)
publicApiRouter.post(
'/api/v1/project/:project_id/comments',
...guard,
TokenApiController.createComment
)
publicApiRouter.post(
'/api/v1/project/:project_id/thread/:thread_id/messages',
...guard,
TokenApiController.sendMessage
)
publicApiRouter.post(
'/api/v1/project/:project_id/doc/:doc_id/thread/:thread_id/resolve',
...guard,
tokenThreadResolveAccess,
TokenApiController.resolveThread
)
publicApiRouter.post(
'/api/v1/project/:project_id/doc/:doc_id/thread/:thread_id/reopen',
...guard,
tokenThreadResolveAccess,
TokenApiController.reopenThread
)
},
}
+14
View File
@@ -0,0 +1,14 @@
import logger from '@overleaf/logger'
import TokenApiRouter from './app/src/TokenApiRouter.mjs'
let TokenApiModule = {}
// Tokens are git-bridge personal access tokens, so the API only exists with the git bridge.
if (process.env.GIT_BRIDGE_ENABLED === 'true') {
logger.debug({}, 'Enabling token-api module')
TokenApiModule = {
router: TokenApiRouter,
}
}
export default TokenApiModule
@@ -0,0 +1,216 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
const { ObjectId } = mongodb
const MODULE_PATH = '../../../app/src/TokenApiAuthMiddleware.mjs'
describe('TokenApiAuthMiddleware', function () {
beforeEach(async function (ctx) {
ctx.userId = new ObjectId().toString()
ctx.projectId = new ObjectId().toString()
ctx.threadId = new ObjectId().toString()
ctx.AuthorizationManager = {
isRestrictedUser: sinon.stub().returns(false),
promises: {
getPrivilegeLevelForProject: sinon.stub(),
},
}
ctx.otherUserId = new ObjectId().toString()
// Chat returns user ids as strings.
ctx.thread = { messages: [{ user_id: ctx.userId, content: 'hi' }] }
ctx.ChatApiHandler = {
promises: {
getThread: sinon.stub().callsFake(async () => ctx.thread),
},
}
ctx.CollaboratorsGetter = {
promises: {
userIsTokenMember: sinon.stub().resolves(false),
isUserInvitedMemberOfProject: sinon.stub().resolves(true),
},
}
ctx.GitBridgePATManager = {
getUserId: sinon.stub().resolves(ctx.userId),
}
vi.doMock(
'../../../../../app/src/Features/Authorization/AuthorizationManager.mjs',
() => ({ default: ctx.AuthorizationManager })
)
vi.doMock(
'../../../../../app/src/Features/Collaborators/CollaboratorsGetter.mjs',
() => ({ default: ctx.CollaboratorsGetter })
)
vi.doMock('../../../../../app/src/Features/Chat/ChatApiHandler.mjs', () => ({
default: ctx.ChatApiHandler,
}))
vi.doMock(
'../../../../git-bridge/app/src/GitBridgePATManager.mjs',
() => ({ default: ctx.GitBridgePATManager })
)
const mod = await import(MODULE_PATH)
ctx.tokenProjectAccess = mod.default
ctx.tokenThreadResolveAccess = mod.tokenThreadResolveAccess
ctx.req = {
headers: { authorization: 'Bearer olp_test' },
params: { project_id: ctx.projectId, thread_id: ctx.threadId },
path: '/api/v1/project/x',
method: 'POST',
}
ctx.res = {
statusCode: 200,
set: sinon.stub(),
on: sinon.stub(),
status: sinon.stub().callsFake(code => {
ctx.res.statusCode = code
return ctx.res
}),
json: sinon.stub(),
}
ctx.next = sinon.stub()
ctx.setLevel = level =>
ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves(
level
)
ctx.expectAllowed = () => {
expect(ctx.next.calledOnceWithExactly()).to.equal(true)
expect(ctx.res.status.called).to.equal(false)
}
ctx.expectForbidden = () => {
expect(ctx.next.called).to.equal(false)
expect(ctx.res.statusCode).to.equal(403)
expect(ctx.res.json.calledWith({ error: 'forbidden' })).to.equal(true)
}
// Runs both middlewares as the resolve route does.
ctx.resolveRoute = async () => {
await ctx.tokenProjectAccess(ctx.req, ctx.res, ctx.next)
if (ctx.next.calledOnceWithExactly()) {
ctx.next.resetHistory()
await ctx.tokenThreadResolveAccess(ctx.req, ctx.res, ctx.next)
}
}
})
describe('restricted user', function () {
beforeEach(function (ctx) {
ctx.setLevel('readOnly')
ctx.CollaboratorsGetter.promises.userIsTokenMember.resolves(true)
ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
false
)
ctx.AuthorizationManager.isRestrictedUser.returns(true)
})
it('answers 404 like a non-member', async function (ctx) {
await ctx.resolveRoute()
expect(ctx.next.called).to.equal(false)
expect(ctx.res.statusCode).to.equal(404)
expect(ctx.res.json.calledWith({ error: 'not found' })).to.equal(true)
expect(
ctx.AuthorizationManager.isRestrictedUser.calledWith(
ctx.userId,
'readOnly',
true,
false
)
).to.equal(true)
})
})
describe('invited read-only member', function () {
beforeEach(function (ctx) {
ctx.setLevel('readOnly')
})
it('may read', async function (ctx) {
await ctx.tokenProjectAccess(ctx.req, ctx.res, ctx.next)
ctx.expectAllowed()
expect(ctx.req.tokenUserId).to.equal(ctx.userId)
})
it('gets 403 on resolve, even on a thread they started', async function (ctx) {
await ctx.resolveRoute()
ctx.expectForbidden()
})
})
for (const level of ['owner', 'readAndWrite']) {
describe(level, function () {
it("may resolve someone else's thread", async function (ctx) {
ctx.setLevel(level)
ctx.thread.messages[0].user_id = ctx.otherUserId
await ctx.resolveRoute()
ctx.expectAllowed()
})
})
}
describe('reviewer', function () {
beforeEach(function (ctx) {
ctx.setLevel('review')
})
it('may resolve a thread they started', async function (ctx) {
await ctx.resolveRoute()
ctx.expectAllowed()
expect(
ctx.ChatApiHandler.promises.getThread.calledWith(
ctx.projectId,
ctx.threadId
)
).to.equal(true)
})
it('may resolve their own thread when the token store holds an ObjectId', async function (ctx) {
ctx.GitBridgePATManager.getUserId.resolves(new ObjectId(ctx.userId))
await ctx.resolveRoute()
ctx.expectAllowed()
})
it("gets 403 on someone else's thread", async function (ctx) {
ctx.thread.messages[0].user_id = ctx.otherUserId
await ctx.resolveRoute()
ctx.expectForbidden()
})
it('gets 403 when they only replied to the thread', async function (ctx) {
ctx.thread.messages = [
{ user_id: ctx.otherUserId, content: 'first' },
{ user_id: ctx.userId, content: 'reply' },
]
await ctx.resolveRoute()
ctx.expectForbidden()
})
it('gets 403 on a thread without messages', async function (ctx) {
ctx.thread.messages = []
await ctx.resolveRoute()
ctx.expectForbidden()
})
it('gets 403 when chat does not know the thread', async function (ctx) {
ctx.ChatApiHandler.promises.getThread.rejects(new Error('not found'))
await ctx.resolveRoute()
ctx.expectForbidden()
})
it("gets 403 on someone else's thread as a site admin", async function (ctx) {
// Core would answer owner for a site admin; the middleware asks with
// ignoreSiteAdmin and so sees the membership level.
ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.callsFake(
async (userId, projectId, token, opts) =>
opts?.ignoreSiteAdmin ? 'review' : 'owner'
)
ctx.thread.messages[0].user_id = ctx.otherUserId
await ctx.resolveRoute()
ctx.expectForbidden()
})
})
})