Collaborative editing (#1660)
This commit is contained in:
1
server/collaboration/Procfile
Normal file
1
server/collaboration/Procfile
Normal file
@@ -0,0 +1 @@
|
||||
web: yarn start --services=collaboration
|
||||
46
server/collaboration/authentication.js
Normal file
46
server/collaboration/authentication.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// @flow
|
||||
import { AuthenticationError } from "../errors";
|
||||
import { Document } from "../models";
|
||||
import policy from "../policies";
|
||||
import { getUserForJWT } from "../utils/jwt";
|
||||
|
||||
const { can } = policy;
|
||||
|
||||
export default class Authentication {
|
||||
async onAuthenticate({
|
||||
connection,
|
||||
token,
|
||||
documentName,
|
||||
}: {
|
||||
connection: { readOnly: boolean },
|
||||
token: string,
|
||||
documentName: string,
|
||||
}) {
|
||||
// allows for different entity types to use this multiplayer provider later
|
||||
const [, documentId] = documentName.split(".");
|
||||
|
||||
if (!token) {
|
||||
throw new AuthenticationError("Authentication required");
|
||||
}
|
||||
|
||||
const user = await getUserForJWT(token);
|
||||
if (user.isSuspended) {
|
||||
throw new AuthenticationError("Account suspended");
|
||||
}
|
||||
|
||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||
if (!can(user, "read", document)) {
|
||||
throw new AuthenticationError("Authorization required");
|
||||
}
|
||||
|
||||
// set document to read only for the current user, thus changes will not be
|
||||
// accepted and synced to other clients
|
||||
if (!can(user, "update", document)) {
|
||||
connection.readOnly = true;
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
};
|
||||
}
|
||||
}
|
||||
71
server/collaboration/persistence.js
Normal file
71
server/collaboration/persistence.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// @flow
|
||||
import debug from "debug";
|
||||
import { debounce } from "lodash";
|
||||
import * as Y from "yjs";
|
||||
import documentUpdater from "../commands/documentUpdater";
|
||||
import { Document, User } from "../models";
|
||||
import markdownToYDoc from "./utils/markdownToYDoc";
|
||||
|
||||
const log = debug("server");
|
||||
const DELAY = 3000;
|
||||
|
||||
export default class Persistence {
|
||||
async onCreateDocument({
|
||||
documentName,
|
||||
...data
|
||||
}: {
|
||||
documentName: string,
|
||||
document: Y.Doc,
|
||||
}) {
|
||||
const [, documentId] = documentName.split(".");
|
||||
const fieldName = "default";
|
||||
|
||||
// Check if the given field already exists in the given y-doc. This is import
|
||||
// so we don't import a document fresh if it exists already.
|
||||
if (!data.document.isEmpty(fieldName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = await Document.findByPk(documentId);
|
||||
|
||||
if (document.state) {
|
||||
const ydoc = new Y.Doc();
|
||||
log(`Document ${documentId} is already in state`);
|
||||
Y.applyUpdate(ydoc, document.state);
|
||||
return ydoc;
|
||||
}
|
||||
|
||||
log(`Document ${documentId} is not in state, creating state from markdown`);
|
||||
const ydoc = markdownToYDoc(document.text, fieldName);
|
||||
const state = Y.encodeStateAsUpdate(ydoc);
|
||||
|
||||
await document.update({ state: Buffer.from(state) }, { hooks: false });
|
||||
return ydoc;
|
||||
}
|
||||
|
||||
onChange = debounce(
|
||||
async ({
|
||||
document,
|
||||
context,
|
||||
documentName,
|
||||
}: {
|
||||
document: Y.Doc,
|
||||
context: { user: User },
|
||||
documentName: string,
|
||||
}) => {
|
||||
const [, documentId] = documentName.split(".");
|
||||
|
||||
log(`persisting ${documentId}`);
|
||||
|
||||
await documentUpdater({
|
||||
documentId,
|
||||
ydoc: document,
|
||||
userId: context.user.id,
|
||||
});
|
||||
},
|
||||
DELAY,
|
||||
{
|
||||
maxWait: DELAY * 3,
|
||||
}
|
||||
);
|
||||
}
|
||||
42
server/collaboration/utils/markdownToYDoc.js
Normal file
42
server/collaboration/utils/markdownToYDoc.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// @flow
|
||||
import { Node, Fragment } from "prosemirror-model";
|
||||
import { parser, schema } from "rich-markdown-editor";
|
||||
import { prosemirrorToYDoc } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import embeds from "../../../shared/embeds";
|
||||
|
||||
export default function markdownToYDoc(
|
||||
markdown: string,
|
||||
fieldName?: string = "default"
|
||||
): Y.Doc {
|
||||
let node = parser.parse(markdown);
|
||||
|
||||
// in rich-markdown-editor embeds were created at runtime by converting links
|
||||
// into embeds where they match. Because we're converting to a CRDT structure
|
||||
// on the server we need to mimic this behavior.
|
||||
function urlsToEmbeds(node: Node): Node {
|
||||
if (node.type.name === "paragraph") {
|
||||
for (const textNode of node.content.content) {
|
||||
for (const embed of embeds) {
|
||||
if (textNode.text && embed.matcher(textNode.text)) {
|
||||
return schema.nodes.embed.createAndFill({
|
||||
href: textNode.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.content) {
|
||||
const contentAsArray =
|
||||
node.content instanceof Fragment ? node.content.content : node.content;
|
||||
node.content = Fragment.fromArray(contentAsArray.map(urlsToEmbeds));
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
node = urlsToEmbeds(node);
|
||||
|
||||
return prosemirrorToYDoc(node, fieldName);
|
||||
}
|
||||
Reference in New Issue
Block a user