mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-15 06:33:55 +01:00
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:
@@ -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),
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+4
@@ -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}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user