mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-04-22 21:07:45 +00:00
feat: don't let read-only users send their cursors or selections
This was done as it may be used to distract or annoy other users either intentionally or unintentionally. Signed-off-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
7636480d8a
commit
2fc89a7de5
6 changed files with 121 additions and 40 deletions
backend/src/realtime/realtime-note
frontend/src/components/editor-page/editor-pane
codemirror-extensions/remote-cursors
hooks/codemirror-extensions
|
@ -52,6 +52,7 @@ export class RealtimeConnection {
|
|||
this.user?.username ?? null,
|
||||
this.getDisplayName(),
|
||||
this,
|
||||
acceptEdits,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -21,17 +21,20 @@ describe('realtime user status adapter', () => {
|
|||
let clientLoggedIn2: RealtimeConnection;
|
||||
let clientGuest: RealtimeConnection;
|
||||
let clientNotReady: RealtimeConnection;
|
||||
let clientDecline: RealtimeConnection;
|
||||
|
||||
let clientLoggedIn1SendMessageSpy: SendMessageSpy;
|
||||
let clientLoggedIn2SendMessageSpy: SendMessageSpy;
|
||||
let clientGuestSendMessageSpy: SendMessageSpy;
|
||||
let clientNotReadySendMessageSpy: SendMessageSpy;
|
||||
let clientDeclineSendMessageSpy: SendMessageSpy;
|
||||
|
||||
let realtimeNote: RealtimeNote;
|
||||
|
||||
const clientLoggedIn1Username = 'logged.in1';
|
||||
const clientLoggedIn2Username = 'logged.in2';
|
||||
const clientNotReadyUsername = 'not.ready';
|
||||
const clientDeclineUsername = 'read.only';
|
||||
|
||||
const guestDisplayName = 'Virtuous Mockingbird';
|
||||
|
||||
|
@ -45,30 +48,36 @@ describe('realtime user status adapter', () => {
|
|||
'mockedContent',
|
||||
);
|
||||
clientLoggedIn1 = new MockConnectionBuilder(realtimeNote)
|
||||
.withRealtimeUserStatus()
|
||||
.withAcceptingRealtimeUserStatus()
|
||||
.withLoggedInUser(clientLoggedIn1Username)
|
||||
.build();
|
||||
clientLoggedIn2 = new MockConnectionBuilder(realtimeNote)
|
||||
.withRealtimeUserStatus()
|
||||
.withAcceptingRealtimeUserStatus()
|
||||
.withLoggedInUser(clientLoggedIn2Username)
|
||||
.build();
|
||||
clientGuest = new MockConnectionBuilder(realtimeNote)
|
||||
.withRealtimeUserStatus()
|
||||
.withAcceptingRealtimeUserStatus()
|
||||
.withGuestUser(guestDisplayName)
|
||||
.build();
|
||||
clientNotReady = new MockConnectionBuilder(realtimeNote)
|
||||
.withRealtimeUserStatus()
|
||||
.withAcceptingRealtimeUserStatus()
|
||||
.withLoggedInUser(clientNotReadyUsername)
|
||||
.build();
|
||||
clientDecline = new MockConnectionBuilder(realtimeNote)
|
||||
.withDecliningRealtimeUserStatus()
|
||||
.withLoggedInUser(clientDeclineUsername)
|
||||
.build();
|
||||
|
||||
clientLoggedIn1SendMessageSpy = spyOnSendMessage(clientLoggedIn1);
|
||||
clientLoggedIn2SendMessageSpy = spyOnSendMessage(clientLoggedIn2);
|
||||
clientGuestSendMessageSpy = spyOnSendMessage(clientGuest);
|
||||
clientNotReadySendMessageSpy = spyOnSendMessage(clientNotReady);
|
||||
clientDeclineSendMessageSpy = spyOnSendMessage(clientDecline);
|
||||
|
||||
clientLoggedIn1.getTransporter().sendReady();
|
||||
clientLoggedIn2.getTransporter().sendReady();
|
||||
clientGuest.getTransporter().sendReady();
|
||||
clientDecline.getTransporter().sendReady();
|
||||
});
|
||||
|
||||
it('can answer a state request', () => {
|
||||
|
@ -76,6 +85,7 @@ describe('realtime user status adapter', () => {
|
|||
expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
clientLoggedIn1
|
||||
.getTransporter()
|
||||
|
@ -119,6 +129,7 @@ describe('realtime user status adapter', () => {
|
|||
expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('can save an cursor update', () => {
|
||||
|
@ -126,6 +137,7 @@ describe('realtime user status adapter', () => {
|
|||
expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
const newFrom = Math.floor(Math.random() * 100);
|
||||
const newTo = Math.floor(Math.random() * 100);
|
||||
|
@ -214,6 +226,7 @@ describe('realtime user status adapter', () => {
|
|||
expectedMessage3,
|
||||
);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('will inform other clients about removed client', () => {
|
||||
|
@ -221,6 +234,7 @@ describe('realtime user status adapter', () => {
|
|||
expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
clientLoggedIn2.getTransporter().disconnect();
|
||||
|
||||
|
@ -278,6 +292,7 @@ describe('realtime user status adapter', () => {
|
|||
expectedMessage3,
|
||||
);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('will inform other clients about inactivity and reactivity', () => {
|
||||
|
@ -285,6 +300,7 @@ describe('realtime user status adapter', () => {
|
|||
expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
clientLoggedIn1
|
||||
.getTransporter()
|
||||
|
@ -371,6 +387,7 @@ describe('realtime user status adapter', () => {
|
|||
expectedInactivityMessage3,
|
||||
);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
clientLoggedIn1
|
||||
.getTransporter()
|
||||
|
@ -391,6 +408,7 @@ describe('realtime user status adapter', () => {
|
|||
expectedInactivityMessage3,
|
||||
);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
clientLoggedIn1
|
||||
.getTransporter()
|
||||
|
@ -477,6 +495,7 @@ describe('realtime user status adapter', () => {
|
|||
expectedReactivityMessage3,
|
||||
);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
clientLoggedIn1
|
||||
.getTransporter()
|
||||
|
@ -497,5 +516,30 @@ describe('realtime user status adapter', () => {
|
|||
expectedReactivityMessage3,
|
||||
);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('will ignore updates from read only clients', () => {
|
||||
expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
clientDecline
|
||||
.getTransporter()
|
||||
.emit(MessageType.REALTIME_USER_SINGLE_UPDATE, {
|
||||
type: MessageType.REALTIME_USER_SINGLE_UPDATE,
|
||||
payload: {
|
||||
from: 0,
|
||||
to: 1234,
|
||||
},
|
||||
});
|
||||
|
||||
expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MessageType, RealtimeUser } from '@hedgedoc/commons';
|
||||
import { Message, MessageType, RealtimeUser } from '@hedgedoc/commons';
|
||||
import { Listener } from 'eventemitter2';
|
||||
|
||||
import { RealtimeConnection } from './realtime-connection';
|
||||
|
@ -19,6 +19,7 @@ export class RealtimeUserStatusAdapter {
|
|||
username: string | null,
|
||||
displayName: string,
|
||||
private connection: RealtimeConnection,
|
||||
private acceptCursorUpdate: boolean,
|
||||
) {
|
||||
this.realtimeUser = this.createInitialRealtimeUserState(
|
||||
username,
|
||||
|
@ -51,13 +52,14 @@ export class RealtimeUserStatusAdapter {
|
|||
const realtimeNote = connection.getRealtimeNote();
|
||||
const transporterMessagesListener = connection.getTransporter().on(
|
||||
MessageType.REALTIME_USER_SINGLE_UPDATE,
|
||||
(message) => {
|
||||
this.realtimeUser.cursor = message.payload;
|
||||
this.sendRealtimeUserStatusUpdateEvent(connection);
|
||||
(message: Message<MessageType.REALTIME_USER_SINGLE_UPDATE>) => {
|
||||
if (this.isAcceptingCursorUpdates()) {
|
||||
this.realtimeUser.cursor = message.payload;
|
||||
this.sendRealtimeUserStatusUpdateEvent(connection);
|
||||
}
|
||||
},
|
||||
{ objectify: true },
|
||||
) as Listener;
|
||||
|
||||
const transporterRequestMessageListener = connection.getTransporter().on(
|
||||
MessageType.REALTIME_USER_STATE_REQUEST,
|
||||
() => {
|
||||
|
@ -80,8 +82,11 @@ export class RealtimeUserStatusAdapter {
|
|||
|
||||
const realtimeUserSetActivityListener = connection.getTransporter().on(
|
||||
MessageType.REALTIME_USER_SET_ACTIVITY,
|
||||
(message) => {
|
||||
if (this.realtimeUser.active === message.payload.active) {
|
||||
(message: Message<MessageType.REALTIME_USER_SET_ACTIVITY>) => {
|
||||
if (
|
||||
!this.isAcceptingCursorUpdates() ||
|
||||
this.realtimeUser.active === message.payload.active
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.realtimeUser.active = message.payload.active;
|
||||
|
@ -91,7 +96,7 @@ export class RealtimeUserStatusAdapter {
|
|||
) as Listener;
|
||||
|
||||
connection.getTransporter().on('disconnected', () => {
|
||||
transporterMessagesListener.off();
|
||||
transporterMessagesListener?.off();
|
||||
transporterRequestMessageListener.off();
|
||||
clientRemoveListener.off();
|
||||
realtimeUserSetActivityListener.off();
|
||||
|
@ -106,25 +111,32 @@ export class RealtimeUserStatusAdapter {
|
|||
);
|
||||
}
|
||||
|
||||
private sendCompleteStateToClient(client: RealtimeConnection): void {
|
||||
const realtimeUsers = this.collectAllConnectionsExcept(client).map(
|
||||
(client) => client.getRealtimeUserStateAdapter().realtimeUser,
|
||||
);
|
||||
private sendCompleteStateToClient(receivingClient: RealtimeConnection): void {
|
||||
const realtimeUser =
|
||||
receivingClient.getRealtimeUserStateAdapter().realtimeUser;
|
||||
const realtimeUsers = this.collectAllConnectionsExcept(receivingClient)
|
||||
.filter((client) =>
|
||||
client.getRealtimeUserStateAdapter().isAcceptingCursorUpdates(),
|
||||
)
|
||||
.map((client) => client.getRealtimeUserStateAdapter().realtimeUser)
|
||||
.filter((realtimeUser) => realtimeUser !== null);
|
||||
|
||||
client.getTransporter().sendMessage({
|
||||
receivingClient.getTransporter().sendMessage({
|
||||
type: MessageType.REALTIME_USER_STATE_SET,
|
||||
payload: {
|
||||
users: realtimeUsers,
|
||||
ownUser: {
|
||||
displayName:
|
||||
client.getRealtimeUserStateAdapter().realtimeUser.displayName,
|
||||
styleIndex:
|
||||
client.getRealtimeUserStateAdapter().realtimeUser.styleIndex,
|
||||
displayName: realtimeUser.displayName,
|
||||
styleIndex: realtimeUser.styleIndex,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private isAcceptingCursorUpdates(): boolean {
|
||||
return this.acceptCursorUpdate;
|
||||
}
|
||||
|
||||
private collectAllConnectionsExcept(
|
||||
exceptClient: RealtimeConnection,
|
||||
): RealtimeConnection[] {
|
||||
|
@ -157,11 +169,13 @@ export class RealtimeUserStatusAdapter {
|
|||
.getConnections()
|
||||
.map(
|
||||
(connection) =>
|
||||
connection.getRealtimeUserStateAdapter().realtimeUser.styleIndex,
|
||||
connection.getRealtimeUserStateAdapter().realtimeUser?.styleIndex,
|
||||
)
|
||||
.reduce((map, styleIndex) => {
|
||||
const count = (map.get(styleIndex) ?? 0) + 1;
|
||||
map.set(styleIndex, count);
|
||||
if (styleIndex !== undefined) {
|
||||
const count = (map.get(styleIndex) ?? 0) + 1;
|
||||
map.set(styleIndex, count);
|
||||
}
|
||||
return map;
|
||||
}, new Map<number, number>());
|
||||
}
|
||||
|
|
|
@ -14,6 +14,12 @@ import { RealtimeConnection } from '../realtime-connection';
|
|||
import { RealtimeNote } from '../realtime-note';
|
||||
import { RealtimeUserStatusAdapter } from '../realtime-user-status-adapter';
|
||||
|
||||
enum RealtimeUserState {
|
||||
WITHOUT,
|
||||
WITH_READWRITE,
|
||||
WITH_READONLY,
|
||||
}
|
||||
|
||||
const MOCK_FALLBACK_USERNAME = 'mock';
|
||||
|
||||
/**
|
||||
|
@ -22,7 +28,8 @@ const MOCK_FALLBACK_USERNAME = 'mock';
|
|||
export class MockConnectionBuilder {
|
||||
private username: string | null;
|
||||
private displayName: string | undefined;
|
||||
private includeRealtimeUserStatus = false;
|
||||
private includeRealtimeUserStatus: RealtimeUserState =
|
||||
RealtimeUserState.WITHOUT;
|
||||
|
||||
constructor(private readonly realtimeNote: RealtimeNote) {}
|
||||
|
||||
|
@ -50,10 +57,18 @@ export class MockConnectionBuilder {
|
|||
}
|
||||
|
||||
/**
|
||||
* Defines that the connection should contain a {@link RealtimeUserStatusAdapter}.
|
||||
* Defines that the connection should contain a {@link RealtimeUserStatusAdapter} that is accepting cursor updates.
|
||||
*/
|
||||
public withRealtimeUserStatus(): this {
|
||||
this.includeRealtimeUserStatus = true;
|
||||
public withAcceptingRealtimeUserStatus(): this {
|
||||
this.includeRealtimeUserStatus = RealtimeUserState.WITH_READWRITE;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines that the connection should contain a {@link RealtimeUserStatusAdapter} that is declining cursor updates.
|
||||
*/
|
||||
public withDecliningRealtimeUserStatus(): this {
|
||||
this.includeRealtimeUserStatus = RealtimeUserState.WITH_READONLY;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
@ -92,11 +107,12 @@ export class MockConnectionBuilder {
|
|||
this.realtimeNote.removeClient(connection),
|
||||
);
|
||||
|
||||
if (this.includeRealtimeUserStatus) {
|
||||
if (this.includeRealtimeUserStatus !== RealtimeUserState.WITHOUT) {
|
||||
realtimeUserStateAdapter = new RealtimeUserStatusAdapter(
|
||||
this.username ?? null,
|
||||
displayName,
|
||||
connection,
|
||||
this.includeRealtimeUserStatus === RealtimeUserState.WITH_READWRITE,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -14,16 +14,18 @@ import type { Listener } from 'eventemitter2'
|
|||
*/
|
||||
export class SendCursorViewPlugin implements PluginValue {
|
||||
private lastCursor: SelectionRange | undefined
|
||||
private listener: Listener
|
||||
private listener?: Listener
|
||||
|
||||
constructor(private view: EditorView, private messageTransporter: MessageTransporter) {
|
||||
this.listener = messageTransporter.doAsSoonAsReady(() => {
|
||||
this.sendCursor(this.lastCursor)
|
||||
})
|
||||
constructor(private view: EditorView, private messageTransporter: MessageTransporter, private mayEdit: boolean) {
|
||||
if (mayEdit) {
|
||||
this.listener = messageTransporter.doAsSoonAsReady(() => {
|
||||
this.sendCursor(this.lastCursor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.listener.off()
|
||||
this.listener?.off()
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
|
@ -37,7 +39,8 @@ export class SendCursorViewPlugin implements PluginValue {
|
|||
if (
|
||||
!this.messageTransporter.isReady() ||
|
||||
currentCursor === undefined ||
|
||||
(this.lastCursor?.to === currentCursor?.to && this.lastCursor?.from === currentCursor?.from)
|
||||
(this.lastCursor?.to === currentCursor?.to && this.lastCursor?.from === currentCursor?.from) ||
|
||||
!this.mayEdit
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useMayEdit } from '../../../../../hooks/common/use-may-edit'
|
||||
import {
|
||||
createCursorLayer,
|
||||
createSelectionLayer,
|
||||
|
@ -19,14 +20,16 @@ import { useMemo } from 'react'
|
|||
* Bundles all extensions that are needed for the remote cursor display.
|
||||
* @return The created codemirror extensions
|
||||
*/
|
||||
export const useCodeMirrorRemoteCursorsExtension = (messageTransporter: MessageTransporter): Extension =>
|
||||
useMemo(
|
||||
export const useCodeMirrorRemoteCursorsExtension = (messageTransporter: MessageTransporter): Extension => {
|
||||
const mayEdit = useMayEdit()
|
||||
return useMemo(
|
||||
() => [
|
||||
remoteCursorStateField.extension,
|
||||
createCursorLayer(),
|
||||
createSelectionLayer(),
|
||||
ViewPlugin.define((view) => new ReceiveRemoteCursorViewPlugin(view, messageTransporter)),
|
||||
ViewPlugin.define((view) => new SendCursorViewPlugin(view, messageTransporter))
|
||||
ViewPlugin.define((view) => new SendCursorViewPlugin(view, messageTransporter, mayEdit))
|
||||
],
|
||||
[messageTransporter]
|
||||
[mayEdit, messageTransporter]
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue