Compare commits

...

8 Commits

Author SHA1 Message Date
Kevin Jahns
2e9a648d08 13.6.1 2023-05-04 11:29:08 +02:00
Kevin Jahns
83712cb1a6 update typings of getAttributes 2023-05-04 11:26:11 +02:00
Kevin Jahns
30b56d5ae9 Enable typings for inserting custom attrs in YXmlElement - fixes #531 2023-05-04 10:07:05 +02:00
Kevin Jahns
adaa95ebb8 add example to createDocFromSnapshot - #159 2023-04-27 18:08:28 +02:00
Kevin Jahns
1f2f08ef7e 13.6.0 2023-04-22 18:41:44 +02:00
Kevin Jahns
39167e6e2a Implement function that obfuscates a ydoc and scrambles its content 2023-04-22 18:39:29 +02:00
Kevin Jahns
5a8519d2c2 13.5.53 2023-04-18 20:09:59 +02:00
Kevin Jahns
d039d48b3f ytext: diff should never create useless delta op 2023-04-18 20:07:17 +02:00
12 changed files with 353 additions and 63 deletions

View File

@@ -753,6 +753,30 @@ currentState1 = Y.mergeUpdates([currentState1, diff2])
currentState1 = Y.mergeUpdates([currentState1, diff1])
```
#### Obfuscating Updates
If one of your users runs into a weird bug (e.g. the rich-text editor throws
error messages), then you don't have to request the full document from your
user. Instead, they can obfuscate the document (i.e. replace the content with
meaningless generated content) before sending it to you. Note that someone might
still deduce the type of content by looking at the general structure of the
document. But this is much better than requesting the original document.
Obfuscated updates contain all the CRDT-related data that is required for
merging. So it is safe to merge obfuscated updates.
```javascript
const ydoc = new Y.Doc()
// perform some changes..
ydoc.getText().insert(0, 'hello world')
const update = Y.encodeStateAsUpdate(ydoc)
// the below update contains scrambled data
const obfuscatedUpdate = Y.obfuscateUpdate(update)
const ydoc2 = new Y.Doc()
Y.applyUpdate(ydoc2, obfuscatedUpdate)
ydoc2.getText().toString() // => "00000000000"
```
#### Using V2 update format
Yjs implements two update formats. By default you are using the V1 update format.

12
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "yjs",
"version": "13.5.52",
"version": "13.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "yjs",
"version": "13.5.52",
"version": "13.6.1",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.72"
"lib0": "^0.2.74"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.1",
@@ -2481,9 +2481,9 @@
}
},
"node_modules/lib0": {
"version": "0.2.73",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.73.tgz",
"integrity": "sha512-aJJIElCLWnHMcYZPtsM07QoSfHwpxCy4VUzBYGXFYEmh/h2QS5uZNbCCfL0CqnkOE30b7Tp9DVfjXag+3qzZjQ==",
"version": "0.2.74",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.74.tgz",
"integrity": "sha512-roj9i46/JwG5ik5KNTkxP2IytlnrssAkD/OhlAVtE+GqectrdkfR+pttszVLrOzMDeXNs1MPt6yo66MUolWSiA==",
"dependencies": {
"isomorphic.js": "^0.2.4"
},

View File

@@ -1,6 +1,6 @@
{
"name": "yjs",
"version": "13.5.52",
"version": "13.6.1",
"description": "Shared Editing Library",
"main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs",
@@ -75,7 +75,7 @@
},
"homepage": "https://docs.yjs.dev",
"dependencies": {
"lib0": "^0.2.72"
"lib0": "^0.2.74"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.1",

View File

@@ -90,6 +90,8 @@ export {
diffUpdateV2,
convertUpdateFormatV1ToV2,
convertUpdateFormatV2ToV1,
obfuscateUpdate,
obfuscateUpdateV2,
UpdateEncoderV1
} from './internals.js'

View File

@@ -206,9 +206,11 @@ export class YMap extends AbstractType {
/**
* Adds or updates an element with a specified key and value.
* @template {MapType} VAL
*
* @param {string} key The key of the element to add to this YMap
* @param {MapType} value The value of the element to add
* @param {VAL} value The value of the element to add
* @return {VAL}
*/
set (key, value) {
if (this.doc !== null) {

View File

@@ -631,36 +631,39 @@ export class YTextEvent extends YEvent {
/**
* @type {any}
*/
let op
let op = null
switch (action) {
case 'delete':
op = { delete: deleteLen }
if (deleteLen > 0) {
op = { delete: deleteLen }
}
deleteLen = 0
break
case 'insert':
op = { insert }
if (currentAttributes.size > 0) {
op.attributes = {}
currentAttributes.forEach((value, key) => {
if (value !== null) {
op.attributes[key] = value
}
})
if (typeof insert === 'object' || insert.length > 0) {
op = { insert }
if (currentAttributes.size > 0) {
op.attributes = {}
currentAttributes.forEach((value, key) => {
if (value !== null) {
op.attributes[key] = value
}
})
}
}
insert = ''
break
case 'retain':
op = { retain }
if (Object.keys(attributes).length > 0) {
op.attributes = {}
for (const key in attributes) {
op.attributes[key] = attributes[key]
if (retain > 0) {
op = { retain }
if (!object.isEmpty(attributes)) {
op.attributes = object.assign({}, attributes)
}
}
retain = 0
break
}
delta.push(op)
if (op) delta.push(op)
action = null
}
}

View File

@@ -1,3 +1,4 @@
import * as object from 'lib0/object'
import {
YXmlFragment,
@@ -12,12 +13,18 @@ import {
YXmlText, ContentType, AbstractType, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Item // eslint-disable-line
} from '../internals.js'
/**
* @typedef {Object|number|null|Array<any>|string|Uint8Array|AbstractType<any>} ValueTypes
*/
/**
* An YXmlElement imitates the behavior of a
* {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}.
*
* * An YXmlElement has attributes (key value pairs)
* * An YXmlElement has childElements that must inherit from YXmlElement
*
* @template {{ [key: string]: ValueTypes }} [KV={ [key: string]: string }]
*/
export class YXmlElement extends YXmlFragment {
constructor (nodeName = 'UNDEFINED') {
@@ -73,14 +80,19 @@ export class YXmlElement extends YXmlFragment {
}
/**
* @return {YXmlElement}
* @return {YXmlElement<KV>}
*/
clone () {
/**
* @type {YXmlElement<KV>}
*/
const el = new YXmlElement(this.nodeName)
const attrs = this.getAttributes()
for (const key in attrs) {
el.setAttribute(key, attrs[key])
}
object.forEach(attrs, (value, key) => {
if (typeof value === 'string') {
el.setAttribute(key, value)
}
})
// @ts-ignore
el.insert(0, this.toArray().map(item => item instanceof AbstractType ? item.clone() : item))
return el
@@ -116,7 +128,7 @@ export class YXmlElement extends YXmlFragment {
/**
* Removes an attribute from this YXmlElement.
*
* @param {String} attributeName The attribute name that is to be removed.
* @param {string} attributeName The attribute name that is to be removed.
*
* @public
*/
@@ -133,8 +145,10 @@ export class YXmlElement extends YXmlFragment {
/**
* Sets or updates an attribute.
*
* @param {String} attributeName The attribute name that is to be set.
* @param {String} attributeValue The attribute value that is to be set.
* @template {keyof KV & string} KEY
*
* @param {KEY} attributeName The attribute name that is to be set.
* @param {KV[KEY]} attributeValue The attribute value that is to be set.
*
* @public
*/
@@ -151,9 +165,11 @@ export class YXmlElement extends YXmlFragment {
/**
* Returns an attribute value that belongs to the attribute name.
*
* @param {String} attributeName The attribute name that identifies the
* @template {keyof KV & string} KEY
*
* @param {KEY} attributeName The attribute name that identifies the
* queried value.
* @return {String} The queried attribute value.
* @return {KV[KEY]|undefined} The queried attribute value.
*
* @public
*/
@@ -164,7 +180,7 @@ export class YXmlElement extends YXmlFragment {
/**
* Returns whether an attribute exists
*
* @param {String} attributeName The attribute name to check for existence.
* @param {string} attributeName The attribute name to check for existence.
* @return {boolean} whether the attribute exists.
*
* @public
@@ -176,12 +192,12 @@ export class YXmlElement extends YXmlFragment {
/**
* Returns all attribute name/value pairs in a JSON Object.
*
* @return {Object<string, any>} A JSON Object that describes the attributes.
* @return {{ [Key in Extract<keyof KV,string>]?: KV[Key]}} A JSON Object that describes the attributes.
*
* @public
*/
getAttributes () {
return typeMapGetAll(this)
return /** @type {any} */ (typeMapGetAll(this))
}
/**
@@ -203,7 +219,10 @@ export class YXmlElement extends YXmlFragment {
const dom = _document.createElement(this.nodeName)
const attrs = this.getAttributes()
for (const key in attrs) {
dom.setAttribute(key, attrs[key])
const value = attrs[key]
if (typeof value === 'string') {
dom.setAttribute(key, value)
}
}
typeListForEach(this, yxml => {
dom.appendChild(yxml.toDOM(_document, hooks, binding))

View File

@@ -153,6 +153,14 @@ export const splitSnapshotAffectedStructs = (transaction, snapshot) => {
}
/**
* @example
* const ydoc = new Y.Doc({ gc: false })
* ydoc.getText().insert(0, 'world!')
* const snapshot = Y.snapshot(ydoc)
* ydoc.getText().insert(0, 'hello ')
* const restored = Y.createDocFromSnapshot(ydoc, snapshot)
* assert(restored.getText().toString() === 'world!')
*
* @param {Doc} originDoc
* @param {Snapshot} snapshot
* @param {Doc} [newDoc] Optionally, you may define the Yjs document that receives the data from originDoc
@@ -161,7 +169,7 @@ export const splitSnapshotAffectedStructs = (transaction, snapshot) => {
export const createDocFromSnapshot = (originDoc, snapshot, newDoc = new Doc()) => {
if (originDoc.gc) {
// we should not try to restore a GC-ed document, because some of the restored items might have their content deleted
throw new Error('originDoc must not be garbage collected')
throw new Error('Garbage-collection must be disabled in `originDoc`!')
}
const { sv, ds } = snapshot

View File

@@ -2,19 +2,40 @@
import * as binary from 'lib0/binary'
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import * as error from 'lib0/error'
import * as f from 'lib0/function'
import * as logging from 'lib0/logging'
import * as map from 'lib0/map'
import * as math from 'lib0/math'
import * as string from 'lib0/string'
import {
ContentAny,
ContentBinary,
ContentDeleted,
ContentDoc,
ContentEmbed,
ContentFormat,
ContentJSON,
ContentString,
ContentType,
createID,
readItemContent,
readDeleteSet,
writeDeleteSet,
Skip,
mergeDeleteSets,
decodeStateVector,
DSEncoderV1,
DSEncoderV2,
decodeStateVector,
Item, GC, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2 // eslint-disable-line
GC,
Item,
mergeDeleteSets,
readDeleteSet,
readItemContent,
Skip,
UpdateDecoderV1,
UpdateDecoderV2,
UpdateEncoderV1,
UpdateEncoderV2,
writeDeleteSet,
YXmlElement,
YXmlHook
} from '../internals.js'
/**
@@ -552,17 +573,17 @@ const finishLazyStructWriting = (lazyWriter) => {
/**
* @param {Uint8Array} update
* @param {function(Item|GC|Skip):Item|GC|Skip} blockTransformer
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} YDecoder
* @param {typeof UpdateEncoderV2 | typeof UpdateEncoderV1 } YEncoder
*/
export const convertUpdateFormat = (update, YDecoder, YEncoder) => {
export const convertUpdateFormat = (update, blockTransformer, YDecoder, YEncoder) => {
const updateDecoder = new YDecoder(decoding.createDecoder(update))
const lazyDecoder = new LazyStructReader(updateDecoder, false)
const updateEncoder = new YEncoder()
const lazyWriter = new LazyStructWriter(updateEncoder)
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
writeStructToLazyStructWriter(lazyWriter, curr, 0)
writeStructToLazyStructWriter(lazyWriter, blockTransformer(curr), 0)
}
finishLazyStructWriting(lazyWriter)
const ds = readDeleteSet(updateDecoder)
@@ -571,11 +592,132 @@ export const convertUpdateFormat = (update, YDecoder, YEncoder) => {
}
/**
* @param {Uint8Array} update
* @typedef {Object} ObfuscatorOptions
* @property {boolean} [ObfuscatorOptions.formatting=true]
* @property {boolean} [ObfuscatorOptions.subdocs=true]
* @property {boolean} [ObfuscatorOptions.yxml=true] Whether to obfuscate nodeName / hookName
*/
export const convertUpdateFormatV1ToV2 = update => convertUpdateFormat(update, UpdateDecoderV1, UpdateEncoderV2)
/**
* @param {ObfuscatorOptions} obfuscator
*/
const createObfuscator = ({ formatting = true, subdocs = true, yxml = true } = {}) => {
let i = 0
const mapKeyCache = map.create()
const nodeNameCache = map.create()
const formattingKeyCache = map.create()
const formattingValueCache = map.create()
formattingValueCache.set(null, null) // end of a formatting range should always be the end of a formatting range
/**
* @param {Item|GC|Skip} block
* @return {Item|GC|Skip}
*/
return block => {
switch (block.constructor) {
case GC:
case Skip:
return block
case Item: {
const item = /** @type {Item} */ (block)
const content = item.content
switch (content.constructor) {
case ContentDeleted:
break
case ContentType: {
if (yxml) {
const type = /** @type {ContentType} */ (content).type
if (type instanceof YXmlElement) {
type.nodeName = map.setIfUndefined(nodeNameCache, type.nodeName, () => 'node-' + i)
}
if (type instanceof YXmlHook) {
type.hookName = map.setIfUndefined(nodeNameCache, type.hookName, () => 'hook-' + i)
}
}
break
}
case ContentAny: {
const c = /** @type {ContentAny} */ (content)
c.arr = c.arr.map(() => i)
break
}
case ContentBinary: {
const c = /** @type {ContentBinary} */ (content)
c.content = new Uint8Array([i])
break
}
case ContentDoc: {
const c = /** @type {ContentDoc} */ (content)
if (subdocs) {
c.opts = {}
c.doc.guid = i + ''
}
break
}
case ContentEmbed: {
const c = /** @type {ContentEmbed} */ (content)
c.embed = {}
break
}
case ContentFormat: {
const c = /** @type {ContentFormat} */ (content)
if (formatting) {
c.key = map.setIfUndefined(formattingKeyCache, c.key, () => i + '')
c.value = map.setIfUndefined(formattingValueCache, c.value, () => ({ i }))
}
break
}
case ContentJSON: {
const c = /** @type {ContentJSON} */ (content)
c.arr = c.arr.map(() => i)
break
}
case ContentString: {
const c = /** @type {ContentString} */ (content)
c.str = string.repeat((i % 10) + '', c.str.length)
break
}
default:
// unknown content type
error.unexpectedCase()
}
if (item.parentSub) {
item.parentSub = map.setIfUndefined(mapKeyCache, item.parentSub, () => i + '')
}
i++
return block
}
default:
// unknown block-type
error.unexpectedCase()
}
}
}
/**
* This function obfuscates the content of a Yjs update. This is useful to share
* buggy Yjs documents while significantly limiting the possibility that a
* developer can on the user. Note that it might still be possible to deduce
* some information by analyzing the "structure" of the document or by analyzing
* the typing behavior using the CRDT-related metadata that is still kept fully
* intact.
*
* @param {Uint8Array} update
* @param {ObfuscatorOptions} [opts]
*/
export const obfuscateUpdate = (update, opts) => convertUpdateFormat(update, createObfuscator(opts), UpdateDecoderV1, UpdateEncoderV1)
/**
* @param {Uint8Array} update
* @param {ObfuscatorOptions} [opts]
*/
export const obfuscateUpdateV2 = (update, opts) => convertUpdateFormat(update, createObfuscator(opts), UpdateDecoderV2, UpdateEncoderV2)
/**
* @param {Uint8Array} update
*/
export const convertUpdateFormatV2ToV1 = update => convertUpdateFormat(update, UpdateDecoderV2, UpdateEncoderV1)
export const convertUpdateFormatV1ToV2 = update => convertUpdateFormat(update, f.id, UpdateDecoderV1, UpdateEncoderV2)
/**
* @param {Uint8Array} update
*/
export const convertUpdateFormatV2ToV1 = update => convertUpdateFormat(update, f.id, UpdateDecoderV2, UpdateEncoderV1)

View File

@@ -2,6 +2,18 @@ import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import { init } from './testHelper.js'
/**
* @param {t.TestCase} tc
*/
export const testBasic = tc => {
const ydoc = new Y.Doc({ gc: false })
ydoc.getText().insert(0, 'world!')
const snapshot = Y.snapshot(ydoc)
ydoc.getText().insert(0, 'hello ')
const restored = Y.createDocFromSnapshot(ydoc, snapshot)
t.assert(restored.getText().toString() === 'world!')
}
/**
* @param {t.TestCase} tc
*/

View File

@@ -4,6 +4,7 @@ import * as Y from '../src/index.js'
import { readClientsStructRefs, readDeleteSet, UpdateDecoderV2, UpdateEncoderV2, writeDeleteSet } from '../src/internals.js'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as object from 'lib0/object'
/**
* @typedef {Object} Enc
@@ -138,7 +139,6 @@ export const testKeyEncoding = tc => {
*/
const checkUpdateCases = (ydoc, updates, enc, hasDeletes) => {
const cases = []
// Case 1: Simple case, simply merge everything
cases.push(enc.mergeUpdates(updates))
@@ -304,3 +304,54 @@ export const testMergePendingUpdates = tc => {
const yText5 = yDoc5.getText('textBlock')
t.compareStrings(yText5.toString(), 'nenor')
}
/**
* @param {t.TestCase} tc
*/
export const testObfuscateUpdates = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText('text')
const ymap = ydoc.getMap('map')
const yarray = ydoc.getArray('array')
// test ytext
ytext.applyDelta([{ insert: 'text', attributes: { bold: true } }, { insert: { href: 'supersecreturl' } }])
// test ymap
ymap.set('key', 'secret1')
ymap.set('key', 'secret2')
// test yarray with subtype & subdoc
const subtype = new Y.XmlElement('secretnodename')
const subdoc = new Y.Doc({ guid: 'secret' })
subtype.setAttribute('attr', 'val')
yarray.insert(0, ['teststring', 42, subtype, subdoc])
// obfuscate the content and put it into a new document
const obfuscatedUpdate = Y.obfuscateUpdate(Y.encodeStateAsUpdate(ydoc))
const odoc = new Y.Doc()
Y.applyUpdate(odoc, obfuscatedUpdate)
const otext = odoc.getText('text')
const omap = odoc.getMap('map')
const oarray = odoc.getArray('array')
// test ytext
const delta = otext.toDelta()
t.assert(delta.length === 2)
t.assert(delta[0].insert !== 'text' && delta[0].insert.length === 4)
t.assert(object.length(delta[0].attributes) === 1)
t.assert(!object.hasProperty(delta[0].attributes, 'bold'))
t.assert(object.length(delta[1]) === 1)
t.assert(object.hasProperty(delta[1], 'insert'))
// test ymap
t.assert(omap.size === 1)
t.assert(!omap.has('key'))
// test yarray with subtype & subdoc
const result = oarray.toArray()
t.assert(result.length === 4)
t.assert(result[0] !== 'teststring')
t.assert(result[1] !== 42)
const osubtype = /** @type {Y.XmlElement} */ (result[2])
const osubdoc = result[3]
// test subtype
t.assert(osubtype.nodeName !== subtype.nodeName)
t.assert(object.length(osubtype.getAttributes()) === 1)
t.assert(osubtype.getAttribute('attr') === undefined)
// test subdoc
t.assert(osubdoc.guid !== subdoc.guid)
}

View File

@@ -3,6 +3,33 @@ import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
export const testCustomTypings = () => {
const ydoc = new Y.Doc()
const ymap = ydoc.getMap()
/**
* @type {Y.XmlElement<{ num: number, str: string, [k:string]: object|number|string }>}
*/
const yxml = ymap.set('yxml', new Y.XmlElement('test'))
/**
* @type {number|undefined}
*/
const num = yxml.getAttribute('num')
/**
* @type {string|undefined}
*/
const str = yxml.getAttribute('str')
/**
* @type {object|number|string|undefined}
*/
const dtrn = yxml.getAttribute('dtrn')
const attrs = yxml.getAttributes()
/**
* @type {object|number|string|undefined}
*/
const any = attrs.shouldBeAny
console.log({ num, str, dtrn, attrs, any })
}
/**
* @param {t.TestCase} tc
*/
@@ -92,9 +119,9 @@ export const testTreewalker = tc => {
}
/**
* @param {t.TestCase} tc
* @param {t.TestCase} _tc
*/
export const testYtextAttributes = tc => {
export const testYtextAttributes = _tc => {
const ydoc = new Y.Doc()
const ytext = /** @type {Y.XmlText} */ (ydoc.get('', Y.XmlText))
ytext.observe(event => {
@@ -106,9 +133,9 @@ export const testYtextAttributes = tc => {
}
/**
* @param {t.TestCase} tc
* @param {t.TestCase} _tc
*/
export const testSiblings = tc => {
export const testSiblings = _tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment()
const first = new Y.XmlText()
@@ -122,9 +149,9 @@ export const testSiblings = tc => {
}
/**
* @param {t.TestCase} tc
* @param {t.TestCase} _tc
*/
export const testInsertafter = tc => {
export const testInsertafter = _tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment()
const first = new Y.XmlText()
@@ -152,9 +179,9 @@ export const testInsertafter = tc => {
}
/**
* @param {t.TestCase} tc
* @param {t.TestCase} _tc
*/
export const testClone = tc => {
export const testClone = _tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment()
const first = new Y.XmlText('text')
@@ -170,9 +197,9 @@ export const testClone = tc => {
}
/**
* @param {t.TestCase} tc
* @param {t.TestCase} _tc
*/
export const testFormattingBug = tc => {
export const testFormattingBug = _tc => {
const ydoc = new Y.Doc()
const yxml = /** @type {Y.XmlText} */ (ydoc.get('', Y.XmlText))
const delta = [