mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Merge pull request #35293 from overleaf/mj-zod-history-ot
[real-time] Allow history-ot operations in real-time GitOrigin-RevId: 4b1b9e4c1a214edf49c5eface46a4d309ba41b0d
This commit is contained in:
committed by
Copybot
parent
9d57689112
commit
d8f167dec2
@@ -11,6 +11,7 @@ import Errors from './Errors.js'
|
||||
import { z, zz } from '@overleaf/validation-tools'
|
||||
import { isZodErrorLike } from 'zod-validation-error'
|
||||
import os from 'node:os'
|
||||
import schemas from 'overleaf-editor-core/lib/schemas.js'
|
||||
|
||||
const { UnexpectedArgumentsError } = Errors
|
||||
|
||||
@@ -60,7 +61,8 @@ const applyOtUpdateSchema = z.object({
|
||||
})
|
||||
)
|
||||
)
|
||||
.min(1),
|
||||
.min(1)
|
||||
.or(z.array(schemas.rawEditOperation).min(1)),
|
||||
v: z.number().int().min(0),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import ConnectedUsersManager from './ConnectedUsersManager.js'
|
||||
import WebsocketLoadBalancer from './WebsocketLoadBalancer.js'
|
||||
import RoomManager from './RoomManager.js'
|
||||
import Errors from './Errors.js'
|
||||
import { EditOperationBuilder } from 'overleaf-editor-core'
|
||||
|
||||
const {
|
||||
CodedError,
|
||||
@@ -658,14 +659,42 @@ export default WebsocketController = {
|
||||
)
|
||||
},
|
||||
|
||||
_isHistoryOTUpdate(update) {
|
||||
for (const op of update.op) {
|
||||
if (!EditOperationBuilder.isValid(op)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
_assertClientCanApplyUpdate(client, docId, update, callback) {
|
||||
if (WebsocketController._isCommentUpdate(update)) {
|
||||
if (WebsocketController._isHistoryOTUpdate(update)) {
|
||||
return WebsocketController._assertClientCanApplyHistoryUpdate(
|
||||
client,
|
||||
docId,
|
||||
update,
|
||||
callback
|
||||
)
|
||||
} else {
|
||||
return WebsocketController._assertClientCanApplyShareJsUpdate(
|
||||
client,
|
||||
docId,
|
||||
update,
|
||||
callback
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
_assertClientCanApplyShareJsUpdate(client, docId, update, callback) {
|
||||
if (WebsocketController._isShareJsCommentUpdate(update)) {
|
||||
return AuthorizationManager.assertClientCanViewProjectAndDoc(
|
||||
client,
|
||||
docId,
|
||||
callback
|
||||
)
|
||||
} else if (update.meta?.tc) {
|
||||
} else if (WebsocketController._isShareJsTrackedChangesUpdate(update)) {
|
||||
return AuthorizationManager.assertClientCanReviewProjectAndDoc(
|
||||
client,
|
||||
docId,
|
||||
@@ -680,10 +709,7 @@ export default WebsocketController = {
|
||||
}
|
||||
},
|
||||
|
||||
_isCommentUpdate(update) {
|
||||
if (!(update && update.op instanceof Array)) {
|
||||
return false
|
||||
}
|
||||
_isShareJsCommentUpdate(update) {
|
||||
for (const op of update.op) {
|
||||
if (!op.c) {
|
||||
return false
|
||||
@@ -691,4 +717,73 @@ export default WebsocketController = {
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
_isShareJsTrackedChangesUpdate(update) {
|
||||
return Boolean(update.meta?.tc)
|
||||
},
|
||||
|
||||
_assertClientCanApplyHistoryUpdate(client, docId, update, callback) {
|
||||
if (WebsocketController._isHistoryOTCommentUpdate(update)) {
|
||||
return AuthorizationManager.assertClientCanViewProjectAndDoc(
|
||||
client,
|
||||
docId,
|
||||
callback
|
||||
)
|
||||
} else if (WebsocketController._isHistoryOTTrackedChangesUpdate(update)) {
|
||||
return AuthorizationManager.assertClientCanReviewProjectAndDoc(
|
||||
client,
|
||||
docId,
|
||||
callback
|
||||
)
|
||||
} else {
|
||||
return AuthorizationManager.assertClientCanEditProjectAndDoc(
|
||||
client,
|
||||
docId,
|
||||
callback
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
_isHistoryOTCommentUpdate(update) {
|
||||
for (const op of update.op) {
|
||||
if (!WebsocketController._isHistoryOTAddCommentOperation(op)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
_isHistoryOTAddCommentOperation(op) {
|
||||
return typeof op === 'object' && 'commentId' in op && 'ranges' in op
|
||||
},
|
||||
|
||||
_isHistoryOTTrackedChangesUpdate(update) {
|
||||
for (const op of update.op) {
|
||||
if (!('textOperation' in op)) {
|
||||
return false
|
||||
}
|
||||
for (const scanOp of op.textOperation) {
|
||||
// Plain retains
|
||||
if (typeof scanOp === 'number' && scanOp > 0) {
|
||||
continue
|
||||
}
|
||||
// Plain insertions
|
||||
if (typeof scanOp !== 'object') {
|
||||
return false
|
||||
}
|
||||
// Tracked insertions
|
||||
if ('i' in scanOp && 'tracking' in scanOp) {
|
||||
continue
|
||||
}
|
||||
// Tracked ranges (we don't accepting by clearing tracking)
|
||||
if ('r' in scanOp && scanOp.tracking?.type !== 'none') {
|
||||
continue
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"express": "4.22.1",
|
||||
"express-session": "^1.17.1",
|
||||
"lodash": "^4.18.1",
|
||||
"overleaf-editor-core": "workspace:*",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"socket.io": "github:overleaf/socket.io#0.9.19-overleaf-12",
|
||||
"socket.io-client": "https://github.com/overleaf/socket.io-client/archive/refs/tags/0.9.17-overleaf-6.tar.gz",
|
||||
|
||||
@@ -1788,5 +1788,107 @@ describe('WebsocketController', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a history-OT client', function () {
|
||||
const tracking = {
|
||||
insert: { type: 'insert', userId: 'user-id-123', ts: '2026-07-15' },
|
||||
delete: { type: 'delete', userId: 'user-id-123', ts: '2026-07-15' },
|
||||
none: { type: 'none' },
|
||||
}
|
||||
|
||||
function expectRoutedTo(ctx, target, update) {
|
||||
return new Promise(resolve => {
|
||||
for (const name of [
|
||||
'assertClientCanViewProjectAndDoc',
|
||||
'assertClientCanReviewProjectAndDoc',
|
||||
'assertClientCanEditProjectAndDoc',
|
||||
]) {
|
||||
ctx.AuthorizationManager[name].yields(new Error('not authorized'))
|
||||
}
|
||||
ctx.AuthorizationManager[target].yields(null)
|
||||
ctx.WebsocketController._assertClientCanApplyUpdate(
|
||||
ctx.client,
|
||||
ctx.doc_id,
|
||||
update,
|
||||
error => {
|
||||
expect(error).to.be.null
|
||||
expect(ctx.AuthorizationManager[target].calledOnce).to.be.true
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
it('should route an add-comment op to view access', async function (ctx) {
|
||||
await expectRoutedTo(ctx, 'assertClientCanViewProjectAndDoc', {
|
||||
op: [{ commentId: 'comment-id', ranges: [{ pos: 0, length: 3 }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should route a tracked insertion to review access', async function (ctx) {
|
||||
await expectRoutedTo(ctx, 'assertClientCanReviewProjectAndDoc', {
|
||||
op: [{ textOperation: [5, { i: 'x', tracking: tracking.insert }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should route a tracked deletion to review access', async function (ctx) {
|
||||
await expectRoutedTo(ctx, 'assertClientCanReviewProjectAndDoc', {
|
||||
op: [{ textOperation: [{ r: 3, tracking: tracking.delete }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should route accepting a tracked change (clearing tracking) to edit access', async function (ctx) {
|
||||
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
|
||||
op: [{ textOperation: [{ r: 3, tracking: tracking.none }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should route an untracked insertion to edit access', async function (ctx) {
|
||||
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
|
||||
op: [{ textOperation: ['plain'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should route an untracked deletion to edit access', async function (ctx) {
|
||||
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
|
||||
op: [{ textOperation: [-3] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should route a mix of tracked and untracked ops to edit access', async function (ctx) {
|
||||
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
|
||||
op: [
|
||||
{ textOperation: [{ i: 'x', tracking: tracking.insert }] },
|
||||
{ textOperation: ['plain'] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should route a resolve-comment op to edit access', async function (ctx) {
|
||||
// Only add-comment ops (commentId + ranges) are recognised as comment
|
||||
// updates; resolving a comment currently requires edit access.
|
||||
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
|
||||
op: [{ commentId: 'comment-id', resolved: true }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should route a permanent history-OT insertion carrying meta.tc to edit access', async function (ctx) {
|
||||
// meta.tc is a legacy (sharejs) tracked-changes id seed and is
|
||||
// meaningless for history-OT, where tracking is carried by the op.
|
||||
// A review-only client must not be able to make a permanent edit by
|
||||
// attaching it.
|
||||
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
|
||||
op: [{ textOperation: ['permanent untracked text'] }],
|
||||
meta: { tc: 'tracked-change-id' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should route a permanent history-OT deletion carrying meta.tc to edit access', async function (ctx) {
|
||||
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
|
||||
op: [{ textOperation: [-3] }],
|
||||
meta: { tc: 'tracked-change-id' },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user