Prevent outdated clients from connecting to collaboration server (#8751)

* Move editor version check to collaboration server connection

* connected -> onConnect

* docs

* Remove hardcoded event codes
This commit is contained in:
Tom Moor
2025-03-22 17:35:45 -04:00
committed by GitHub
parent 6fde025ce4
commit 0ec6440506
8 changed files with 108 additions and 20 deletions
+10 -4
View File
@@ -4,6 +4,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -14,21 +20,21 @@ function ConnectionStatus() {
const { t } = useTranslation();
const codeToMessage = {
1009: {
[DocumentTooLarge.code]: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
4401: {
[AuthenticationFailed.code]: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
4403: {
[AuthorizationFailed.code]: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
4503: {
[TooManyConnections.code]: {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
@@ -6,6 +6,12 @@ import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
AuthenticationFailed,
DocumentTooLarge,
EditorUpdateError,
} from "@shared/collaboration/CloseEvents";
import EDITOR_VERSION from "@shared/editor/version";
import { supportsPassiveListener } from "@shared/utils/browser";
import Editor, { Props as EditorProps } from "~/components/Editor";
import MultiplayerExtension from "~/editor/extensions/Multiplayer";
@@ -65,6 +71,9 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
const name = `document.${documentId}`;
const localProvider = new IndexeddbPersistence(name, ydoc);
const provider = new HocuspocusProvider({
parameters: {
editorVersion: EDITOR_VERSION,
},
url: `${env.COLLABORATION_URL}/collaboration`,
name,
document: ydoc,
@@ -140,8 +149,14 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
provider.on("close", (ev: MessageEvent) => {
if ("code" in ev.event) {
provider.shouldConnect =
ev.event.code !== 1009 && ev.event.code !== 4401;
ev.event.code !== DocumentTooLarge.code &&
ev.event.code !== AuthenticationFailed.code &&
ev.event.code !== EditorUpdateError.code;
ui.setMultiplayerStatus("disconnected", ev.event.code);
if (ev.event.code === EditorUpdateError.code) {
window.location.reload();
}
}
});
-4
View File
@@ -1,4 +0,0 @@
export const TooManyConnections = {
code: 4503,
reason: "Too Many Connections",
};
@@ -1,12 +1,12 @@
import {
Extension,
connectedPayload,
onConnectPayload,
onDisconnectPayload,
} from "@hocuspocus/server";
import { TooManyConnections } from "@shared/collaboration/CloseEvents";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing";
import { TooManyConnections } from "./CloseEvents";
import { withContext } from "./types";
@trace()
@@ -17,8 +17,10 @@ export class ConnectionLimitExtension implements Extension {
connectionsByDocument: Map<string, Set<string>> = new Map();
/**
* onDisconnect hook
* On disconnect hook
*
* @param data The disconnect payload
* @returns Promise
*/
onDisconnect({ documentName, socketId }: withContext<onDisconnectPayload>) {
const connections = this.connectionsByDocument.get(documentName);
@@ -41,10 +43,12 @@ export class ConnectionLimitExtension implements Extension {
}
/**
* connected hook
* @param data The connected payload
* On connect hook
*
* @param data The connect payload
* @returns Promise, resolving will allow the connection, rejecting will drop it
*/
connected({ documentName, socketId }: withContext<connectedPayload>) {
onConnect({ documentName, socketId }: withContext<onConnectPayload>) {
const connections =
this.connectionsByDocument.get(documentName) || new Set();
if (connections?.size >= env.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT) {
@@ -0,0 +1,40 @@
import { Extension, onConnectPayload } from "@hocuspocus/server";
import semver from "semver";
import { EditorUpdateError } from "@shared/collaboration/CloseEvents";
import EDITOR_VERSION from "@shared/editor/version";
import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing";
import { withContext } from "./types";
@trace()
export class EditorVersionExtension implements Extension {
/**
* On connect hook prevents connections from clients with an outdated editor
* version. See the equivalent logic for API in /server/routes/api/middlewares/editor.ts
*
* @param data The connect payload
* @returns Promise, resolving will allow the connection, rejecting will drop.
*/
onConnect({ requestParameters }: withContext<onConnectPayload>) {
const clientVersion = requestParameters.get("editorVersion");
if (clientVersion) {
const parsedClientVersion = semver.parse(clientVersion);
const parsedServerVersion = semver.parse(EDITOR_VERSION);
if (
parsedClientVersion &&
parsedServerVersion &&
parsedClientVersion.major < parsedServerVersion.major
) {
Logger.debug(
"multiplayer",
`Dropping connection due to outdated editor version: ${clientVersion} < ${EDITOR_VERSION}`
);
return Promise.reject(EditorUpdateError);
}
}
return Promise.resolve();
}
}
+6 -5
View File
@@ -4,12 +4,14 @@ import EDITOR_VERSION from "@shared/editor/version";
import { EditorUpdateError } from "@server/errors";
export default function editor() {
/**
* Middleware to prevent connections from clients with an outdated editor
* version. See the equivalent logic for collab server in:
* /server/collaboration/EditorVersionExtension.ts
*/
return async function editorMiddleware(ctx: Context, next: Next) {
const clientVersion = ctx.headers["x-editor-version"];
// If the editor version on the client is behind the current version being
// served in production by either a minor (new features), or major (breaking
// changes) then force a client reload.
if (clientVersion) {
const parsedClientVersion = semver.parse(clientVersion as string);
const parsedCurrentVersion = semver.parse(EDITOR_VERSION);
@@ -17,8 +19,7 @@ export default function editor() {
if (
parsedClientVersion &&
parsedCurrentVersion &&
(parsedClientVersion.major < parsedCurrentVersion.major ||
parsedClientVersion.minor < parsedCurrentVersion.minor)
parsedClientVersion.major < parsedCurrentVersion.major
) {
throw EditorUpdateError();
}
+2
View File
@@ -12,6 +12,7 @@ import env from "@server/env";
import Logger from "@server/logging/Logger";
import ShutdownHelper, { ShutdownOrder } from "@server/utils/ShutdownHelper";
import AuthenticationExtension from "../collaboration/AuthenticationExtension";
import { EditorVersionExtension } from "../collaboration/EditorVersionExtension";
import LoggerExtension from "../collaboration/LoggerExtension";
import MetricsExtension from "../collaboration/MetricsExtension";
import PersistenceExtension from "../collaboration/PersistenceExtension";
@@ -39,6 +40,7 @@ export default function init(
banTime: 5,
}),
new ConnectionLimitExtension(),
new EditorVersionExtension(),
new AuthenticationExtension(),
new PersistenceExtension(),
new ViewsExtension(),
+24
View File
@@ -0,0 +1,24 @@
export const DocumentTooLarge = {
code: 1009,
reason: "Document Too Large",
};
export const AuthenticationFailed = {
code: 4401,
reason: "Authentication Failed",
};
export const AuthorizationFailed = {
code: 4403,
reason: "Authorization Failed",
};
export const TooManyConnections = {
code: 4503,
reason: "Too Many Connections",
};
export const EditorUpdateError = {
code: 5000,
reason: "Editor Update Required",
};