mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
Reference in New Issue
Block a user