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:
Philip Molares 2023-03-30 21:30:32 +02:00 committed by Tilman Vatteroth
parent 7636480d8a
commit 2fc89a7de5
6 changed files with 121 additions and 40 deletions

View file

@ -52,6 +52,7 @@ export class RealtimeConnection {
this.user?.username ?? null, this.user?.username ?? null,
this.getDisplayName(), this.getDisplayName(),
this, this,
acceptEdits,
); );
} }

View file

@ -21,17 +21,20 @@ describe('realtime user status adapter', () => {
let clientLoggedIn2: RealtimeConnection; let clientLoggedIn2: RealtimeConnection;
let clientGuest: RealtimeConnection; let clientGuest: RealtimeConnection;
let clientNotReady: RealtimeConnection; let clientNotReady: RealtimeConnection;
let clientDecline: RealtimeConnection;
let clientLoggedIn1SendMessageSpy: SendMessageSpy; let clientLoggedIn1SendMessageSpy: SendMessageSpy;
let clientLoggedIn2SendMessageSpy: SendMessageSpy; let clientLoggedIn2SendMessageSpy: SendMessageSpy;
let clientGuestSendMessageSpy: SendMessageSpy; let clientGuestSendMessageSpy: SendMessageSpy;
let clientNotReadySendMessageSpy: SendMessageSpy; let clientNotReadySendMessageSpy: SendMessageSpy;
let clientDeclineSendMessageSpy: SendMessageSpy;
let realtimeNote: RealtimeNote; let realtimeNote: RealtimeNote;
const clientLoggedIn1Username = 'logged.in1'; const clientLoggedIn1Username = 'logged.in1';
const clientLoggedIn2Username = 'logged.in2'; const clientLoggedIn2Username = 'logged.in2';
const clientNotReadyUsername = 'not.ready'; const clientNotReadyUsername = 'not.ready';
const clientDeclineUsername = 'read.only';
const guestDisplayName = 'Virtuous Mockingbird'; const guestDisplayName = 'Virtuous Mockingbird';
@ -45,30 +48,36 @@ describe('realtime user status adapter', () => {
'mockedContent', 'mockedContent',
); );
clientLoggedIn1 = new MockConnectionBuilder(realtimeNote) clientLoggedIn1 = new MockConnectionBuilder(realtimeNote)
.withRealtimeUserStatus() .withAcceptingRealtimeUserStatus()
.withLoggedInUser(clientLoggedIn1Username) .withLoggedInUser(clientLoggedIn1Username)
.build(); .build();
clientLoggedIn2 = new MockConnectionBuilder(realtimeNote) clientLoggedIn2 = new MockConnectionBuilder(realtimeNote)
.withRealtimeUserStatus() .withAcceptingRealtimeUserStatus()
.withLoggedInUser(clientLoggedIn2Username) .withLoggedInUser(clientLoggedIn2Username)
.build(); .build();
clientGuest = new MockConnectionBuilder(realtimeNote) clientGuest = new MockConnectionBuilder(realtimeNote)
.withRealtimeUserStatus() .withAcceptingRealtimeUserStatus()
.withGuestUser(guestDisplayName) .withGuestUser(guestDisplayName)
.build(); .build();
clientNotReady = new MockConnectionBuilder(realtimeNote) clientNotReady = new MockConnectionBuilder(realtimeNote)
.withRealtimeUserStatus() .withAcceptingRealtimeUserStatus()
.withLoggedInUser(clientNotReadyUsername) .withLoggedInUser(clientNotReadyUsername)
.build(); .build();
clientDecline = new MockConnectionBuilder(realtimeNote)
.withDecliningRealtimeUserStatus()
.withLoggedInUser(clientDeclineUsername)
.build();
clientLoggedIn1SendMessageSpy = spyOnSendMessage(clientLoggedIn1); clientLoggedIn1SendMessageSpy = spyOnSendMessage(clientLoggedIn1);
clientLoggedIn2SendMessageSpy = spyOnSendMessage(clientLoggedIn2); clientLoggedIn2SendMessageSpy = spyOnSendMessage(clientLoggedIn2);
clientGuestSendMessageSpy = spyOnSendMessage(clientGuest); clientGuestSendMessageSpy = spyOnSendMessage(clientGuest);
clientNotReadySendMessageSpy = spyOnSendMessage(clientNotReady); clientNotReadySendMessageSpy = spyOnSendMessage(clientNotReady);
clientDeclineSendMessageSpy = spyOnSendMessage(clientDecline);
clientLoggedIn1.getTransporter().sendReady(); clientLoggedIn1.getTransporter().sendReady();
clientLoggedIn2.getTransporter().sendReady(); clientLoggedIn2.getTransporter().sendReady();
clientGuest.getTransporter().sendReady(); clientGuest.getTransporter().sendReady();
clientDecline.getTransporter().sendReady();
}); });
it('can answer a state request', () => { it('can answer a state request', () => {
@ -76,6 +85,7 @@ describe('realtime user status adapter', () => {
expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0);
clientLoggedIn1 clientLoggedIn1
.getTransporter() .getTransporter()
@ -119,6 +129,7 @@ describe('realtime user status adapter', () => {
expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0);
}); });
it('can save an cursor update', () => { it('can save an cursor update', () => {
@ -126,6 +137,7 @@ describe('realtime user status adapter', () => {
expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0);
const newFrom = Math.floor(Math.random() * 100); const newFrom = Math.floor(Math.random() * 100);
const newTo = Math.floor(Math.random() * 100); const newTo = Math.floor(Math.random() * 100);
@ -214,6 +226,7 @@ describe('realtime user status adapter', () => {
expectedMessage3, expectedMessage3,
); );
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1);
}); });
it('will inform other clients about removed client', () => { it('will inform other clients about removed client', () => {
@ -221,6 +234,7 @@ describe('realtime user status adapter', () => {
expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0);
clientLoggedIn2.getTransporter().disconnect(); clientLoggedIn2.getTransporter().disconnect();
@ -278,6 +292,7 @@ describe('realtime user status adapter', () => {
expectedMessage3, expectedMessage3,
); );
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1);
}); });
it('will inform other clients about inactivity and reactivity', () => { it('will inform other clients about inactivity and reactivity', () => {
@ -285,6 +300,7 @@ describe('realtime user status adapter', () => {
expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0);
clientLoggedIn1 clientLoggedIn1
.getTransporter() .getTransporter()
@ -371,6 +387,7 @@ describe('realtime user status adapter', () => {
expectedInactivityMessage3, expectedInactivityMessage3,
); );
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1);
clientLoggedIn1 clientLoggedIn1
.getTransporter() .getTransporter()
@ -391,6 +408,7 @@ describe('realtime user status adapter', () => {
expectedInactivityMessage3, expectedInactivityMessage3,
); );
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1);
clientLoggedIn1 clientLoggedIn1
.getTransporter() .getTransporter()
@ -477,6 +495,7 @@ describe('realtime user status adapter', () => {
expectedReactivityMessage3, expectedReactivityMessage3,
); );
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0);
expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(2);
clientLoggedIn1 clientLoggedIn1
.getTransporter() .getTransporter()
@ -497,5 +516,30 @@ describe('realtime user status adapter', () => {
expectedReactivityMessage3, expectedReactivityMessage3,
); );
expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); 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);
}); });
}); });

View file

@ -3,7 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * 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 { Listener } from 'eventemitter2';
import { RealtimeConnection } from './realtime-connection'; import { RealtimeConnection } from './realtime-connection';
@ -19,6 +19,7 @@ export class RealtimeUserStatusAdapter {
username: string | null, username: string | null,
displayName: string, displayName: string,
private connection: RealtimeConnection, private connection: RealtimeConnection,
private acceptCursorUpdate: boolean,
) { ) {
this.realtimeUser = this.createInitialRealtimeUserState( this.realtimeUser = this.createInitialRealtimeUserState(
username, username,
@ -51,13 +52,14 @@ export class RealtimeUserStatusAdapter {
const realtimeNote = connection.getRealtimeNote(); const realtimeNote = connection.getRealtimeNote();
const transporterMessagesListener = connection.getTransporter().on( const transporterMessagesListener = connection.getTransporter().on(
MessageType.REALTIME_USER_SINGLE_UPDATE, MessageType.REALTIME_USER_SINGLE_UPDATE,
(message) => { (message: Message<MessageType.REALTIME_USER_SINGLE_UPDATE>) => {
this.realtimeUser.cursor = message.payload; if (this.isAcceptingCursorUpdates()) {
this.sendRealtimeUserStatusUpdateEvent(connection); this.realtimeUser.cursor = message.payload;
this.sendRealtimeUserStatusUpdateEvent(connection);
}
}, },
{ objectify: true }, { objectify: true },
) as Listener; ) as Listener;
const transporterRequestMessageListener = connection.getTransporter().on( const transporterRequestMessageListener = connection.getTransporter().on(
MessageType.REALTIME_USER_STATE_REQUEST, MessageType.REALTIME_USER_STATE_REQUEST,
() => { () => {
@ -80,8 +82,11 @@ export class RealtimeUserStatusAdapter {
const realtimeUserSetActivityListener = connection.getTransporter().on( const realtimeUserSetActivityListener = connection.getTransporter().on(
MessageType.REALTIME_USER_SET_ACTIVITY, MessageType.REALTIME_USER_SET_ACTIVITY,
(message) => { (message: Message<MessageType.REALTIME_USER_SET_ACTIVITY>) => {
if (this.realtimeUser.active === message.payload.active) { if (
!this.isAcceptingCursorUpdates() ||
this.realtimeUser.active === message.payload.active
) {
return; return;
} }
this.realtimeUser.active = message.payload.active; this.realtimeUser.active = message.payload.active;
@ -91,7 +96,7 @@ export class RealtimeUserStatusAdapter {
) as Listener; ) as Listener;
connection.getTransporter().on('disconnected', () => { connection.getTransporter().on('disconnected', () => {
transporterMessagesListener.off(); transporterMessagesListener?.off();
transporterRequestMessageListener.off(); transporterRequestMessageListener.off();
clientRemoveListener.off(); clientRemoveListener.off();
realtimeUserSetActivityListener.off(); realtimeUserSetActivityListener.off();
@ -106,25 +111,32 @@ export class RealtimeUserStatusAdapter {
); );
} }
private sendCompleteStateToClient(client: RealtimeConnection): void { private sendCompleteStateToClient(receivingClient: RealtimeConnection): void {
const realtimeUsers = this.collectAllConnectionsExcept(client).map( const realtimeUser =
(client) => client.getRealtimeUserStateAdapter().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, type: MessageType.REALTIME_USER_STATE_SET,
payload: { payload: {
users: realtimeUsers, users: realtimeUsers,
ownUser: { ownUser: {
displayName: displayName: realtimeUser.displayName,
client.getRealtimeUserStateAdapter().realtimeUser.displayName, styleIndex: realtimeUser.styleIndex,
styleIndex:
client.getRealtimeUserStateAdapter().realtimeUser.styleIndex,
}, },
}, },
}); });
} }
private isAcceptingCursorUpdates(): boolean {
return this.acceptCursorUpdate;
}
private collectAllConnectionsExcept( private collectAllConnectionsExcept(
exceptClient: RealtimeConnection, exceptClient: RealtimeConnection,
): RealtimeConnection[] { ): RealtimeConnection[] {
@ -157,11 +169,13 @@ export class RealtimeUserStatusAdapter {
.getConnections() .getConnections()
.map( .map(
(connection) => (connection) =>
connection.getRealtimeUserStateAdapter().realtimeUser.styleIndex, connection.getRealtimeUserStateAdapter().realtimeUser?.styleIndex,
) )
.reduce((map, styleIndex) => { .reduce((map, styleIndex) => {
const count = (map.get(styleIndex) ?? 0) + 1; if (styleIndex !== undefined) {
map.set(styleIndex, count); const count = (map.get(styleIndex) ?? 0) + 1;
map.set(styleIndex, count);
}
return map; return map;
}, new Map<number, number>()); }, new Map<number, number>());
} }

View file

@ -14,6 +14,12 @@ import { RealtimeConnection } from '../realtime-connection';
import { RealtimeNote } from '../realtime-note'; import { RealtimeNote } from '../realtime-note';
import { RealtimeUserStatusAdapter } from '../realtime-user-status-adapter'; import { RealtimeUserStatusAdapter } from '../realtime-user-status-adapter';
enum RealtimeUserState {
WITHOUT,
WITH_READWRITE,
WITH_READONLY,
}
const MOCK_FALLBACK_USERNAME = 'mock'; const MOCK_FALLBACK_USERNAME = 'mock';
/** /**
@ -22,7 +28,8 @@ const MOCK_FALLBACK_USERNAME = 'mock';
export class MockConnectionBuilder { export class MockConnectionBuilder {
private username: string | null; private username: string | null;
private displayName: string | undefined; private displayName: string | undefined;
private includeRealtimeUserStatus = false; private includeRealtimeUserStatus: RealtimeUserState =
RealtimeUserState.WITHOUT;
constructor(private readonly realtimeNote: RealtimeNote) {} 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 { public withAcceptingRealtimeUserStatus(): this {
this.includeRealtimeUserStatus = true; 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; return this;
} }
@ -92,11 +107,12 @@ export class MockConnectionBuilder {
this.realtimeNote.removeClient(connection), this.realtimeNote.removeClient(connection),
); );
if (this.includeRealtimeUserStatus) { if (this.includeRealtimeUserStatus !== RealtimeUserState.WITHOUT) {
realtimeUserStateAdapter = new RealtimeUserStatusAdapter( realtimeUserStateAdapter = new RealtimeUserStatusAdapter(
this.username ?? null, this.username ?? null,
displayName, displayName,
connection, connection,
this.includeRealtimeUserStatus === RealtimeUserState.WITH_READWRITE,
); );
} }

View file

@ -14,16 +14,18 @@ import type { Listener } from 'eventemitter2'
*/ */
export class SendCursorViewPlugin implements PluginValue { export class SendCursorViewPlugin implements PluginValue {
private lastCursor: SelectionRange | undefined private lastCursor: SelectionRange | undefined
private listener: Listener private listener?: Listener
constructor(private view: EditorView, private messageTransporter: MessageTransporter) { constructor(private view: EditorView, private messageTransporter: MessageTransporter, private mayEdit: boolean) {
this.listener = messageTransporter.doAsSoonAsReady(() => { if (mayEdit) {
this.sendCursor(this.lastCursor) this.listener = messageTransporter.doAsSoonAsReady(() => {
}) this.sendCursor(this.lastCursor)
})
}
} }
destroy() { destroy() {
this.listener.off() this.listener?.off()
} }
update(update: ViewUpdate) { update(update: ViewUpdate) {
@ -37,7 +39,8 @@ export class SendCursorViewPlugin implements PluginValue {
if ( if (
!this.messageTransporter.isReady() || !this.messageTransporter.isReady() ||
currentCursor === undefined || 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 return
} }

View file

@ -3,6 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useMayEdit } from '../../../../../hooks/common/use-may-edit'
import { import {
createCursorLayer, createCursorLayer,
createSelectionLayer, createSelectionLayer,
@ -19,14 +20,16 @@ import { useMemo } from 'react'
* Bundles all extensions that are needed for the remote cursor display. * Bundles all extensions that are needed for the remote cursor display.
* @return The created codemirror extensions * @return The created codemirror extensions
*/ */
export const useCodeMirrorRemoteCursorsExtension = (messageTransporter: MessageTransporter): Extension => export const useCodeMirrorRemoteCursorsExtension = (messageTransporter: MessageTransporter): Extension => {
useMemo( const mayEdit = useMayEdit()
return useMemo(
() => [ () => [
remoteCursorStateField.extension, remoteCursorStateField.extension,
createCursorLayer(), createCursorLayer(),
createSelectionLayer(), createSelectionLayer(),
ViewPlugin.define((view) => new ReceiveRemoteCursorViewPlugin(view, messageTransporter)), 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]
) )
}