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.getDisplayName(),
this,
acceptEdits,
);
}

View file

@ -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);
});
});

View file

@ -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>());
}

View file

@ -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,
);
}

View file

@ -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
}

View file

@ -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]
)
}