mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-25 03:06:31 -05:00
feat: check permissions in realtime code and frontend
Signed-off-by: Philip Molares <philip.molares@udo.edu> Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
24f1b2a361
commit
c2f41118b6
27 changed files with 287 additions and 66 deletions
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -53,6 +53,7 @@ describe('websocket connection', () => {
|
||||||
mockedMessageTransporter,
|
mockedMessageTransporter,
|
||||||
mockedUser,
|
mockedUser,
|
||||||
mockedRealtimeNote,
|
mockedRealtimeNote,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
expect(sut.getTransporter()).toBe(mockedMessageTransporter);
|
expect(sut.getTransporter()).toBe(mockedMessageTransporter);
|
||||||
});
|
});
|
||||||
|
@ -62,6 +63,7 @@ describe('websocket connection', () => {
|
||||||
mockedMessageTransporter,
|
mockedMessageTransporter,
|
||||||
mockedUser,
|
mockedUser,
|
||||||
mockedRealtimeNote,
|
mockedRealtimeNote,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
expect(sut.getRealtimeNote()).toBe(mockedRealtimeNote);
|
expect(sut.getRealtimeNote()).toBe(mockedRealtimeNote);
|
||||||
});
|
});
|
||||||
|
@ -76,6 +78,7 @@ describe('websocket connection', () => {
|
||||||
mockedMessageTransporter,
|
mockedMessageTransporter,
|
||||||
mockedUser,
|
mockedUser,
|
||||||
mockedRealtimeNote,
|
mockedRealtimeNote,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(sut.getRealtimeUserStateAdapter()).toBe(realtimeUserStatus);
|
expect(sut.getRealtimeUserStateAdapter()).toBe(realtimeUserStatus);
|
||||||
|
@ -91,6 +94,7 @@ describe('websocket connection', () => {
|
||||||
mockedMessageTransporter,
|
mockedMessageTransporter,
|
||||||
mockedUser,
|
mockedUser,
|
||||||
mockedRealtimeNote,
|
mockedRealtimeNote,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(sut.getSyncAdapter()).toBe(yDocSyncServerAdapter);
|
expect(sut.getSyncAdapter()).toBe(yDocSyncServerAdapter);
|
||||||
|
@ -101,6 +105,7 @@ describe('websocket connection', () => {
|
||||||
mockedMessageTransporter,
|
mockedMessageTransporter,
|
||||||
mockedUser,
|
mockedUser,
|
||||||
mockedRealtimeNote,
|
mockedRealtimeNote,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient');
|
const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient');
|
||||||
|
@ -115,6 +120,7 @@ describe('websocket connection', () => {
|
||||||
mockedMessageTransporter,
|
mockedMessageTransporter,
|
||||||
mockedUser,
|
mockedUser,
|
||||||
mockedRealtimeNote,
|
mockedRealtimeNote,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(sut.getUser()).toBe(mockedUser);
|
expect(sut.getUser()).toBe(mockedUser);
|
||||||
|
@ -127,6 +133,7 @@ describe('websocket connection', () => {
|
||||||
mockedMessageTransporter,
|
mockedMessageTransporter,
|
||||||
mockedUserWithUsername,
|
mockedUserWithUsername,
|
||||||
mockedRealtimeNote,
|
mockedRealtimeNote,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(sut.getDisplayName()).toBe('MockUser');
|
expect(sut.getDisplayName()).toBe('MockUser');
|
||||||
|
@ -143,6 +150,7 @@ describe('websocket connection', () => {
|
||||||
mockedMessageTransporter,
|
mockedMessageTransporter,
|
||||||
mockedUser,
|
mockedUser,
|
||||||
mockedRealtimeNote,
|
mockedRealtimeNote,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(sut.getDisplayName()).toBe(randomName);
|
expect(sut.getDisplayName()).toBe(randomName);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -28,12 +28,14 @@ export class RealtimeConnection {
|
||||||
* @param messageTransporter The message transporter that handles the communication with the client.
|
* @param messageTransporter The message transporter that handles the communication with the client.
|
||||||
* @param user The user of the client
|
* @param user The user of the client
|
||||||
* @param realtimeNote The {@link RealtimeNote} that the client connected to.
|
* @param realtimeNote The {@link RealtimeNote} that the client connected to.
|
||||||
|
* @param acceptEdits If edits by this connection should be accepted.
|
||||||
* @throws Error if the socket is not open
|
* @throws Error if the socket is not open
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
messageTransporter: MessageTransporter,
|
messageTransporter: MessageTransporter,
|
||||||
private user: User | null,
|
private user: User | null,
|
||||||
private realtimeNote: RealtimeNote,
|
private realtimeNote: RealtimeNote,
|
||||||
|
private acceptEdits: boolean,
|
||||||
) {
|
) {
|
||||||
this.displayName = user?.displayName ?? generateRandomName();
|
this.displayName = user?.displayName ?? generateRandomName();
|
||||||
this.transporter = messageTransporter;
|
this.transporter = messageTransporter;
|
||||||
|
@ -44,6 +46,7 @@ export class RealtimeConnection {
|
||||||
this.yDocSyncAdapter = new YDocSyncServerAdapter(
|
this.yDocSyncAdapter = new YDocSyncServerAdapter(
|
||||||
this.transporter,
|
this.transporter,
|
||||||
realtimeNote.getRealtimeDoc(),
|
realtimeNote.getRealtimeDoc(),
|
||||||
|
acceptEdits,
|
||||||
);
|
);
|
||||||
this.realtimeUserStateAdapter = new RealtimeUserStatusAdapter(
|
this.realtimeUserStateAdapter = new RealtimeUserStatusAdapter(
|
||||||
this.user?.username ?? null,
|
this.user?.username ?? null,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
@ -195,8 +195,18 @@ describe('Websocket gateway', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockedNote = Mock.of<Note>({ id: 4711 });
|
const mockedNote = Mock.of<Note>({
|
||||||
const mockedGuestNote = Mock.of<Note>({ id: 1235 });
|
id: 4711,
|
||||||
|
owner: Promise.resolve(mockUser),
|
||||||
|
userPermissions: Promise.resolve([]),
|
||||||
|
groupPermissions: Promise.resolve([]),
|
||||||
|
});
|
||||||
|
const mockedGuestNote = Mock.of<Note>({
|
||||||
|
id: 1235,
|
||||||
|
owner: Promise.resolve(null),
|
||||||
|
userPermissions: Promise.resolve([]),
|
||||||
|
groupPermissions: Promise.resolve([]),
|
||||||
|
});
|
||||||
jest
|
jest
|
||||||
.spyOn(notesService, 'getNoteByIdOrAlias')
|
.spyOn(notesService, 'getNoteByIdOrAlias')
|
||||||
.mockImplementation((noteId: string) => {
|
.mockImplementation((noteId: string) => {
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { WebsocketTransporter } from '@hedgedoc/commons';
|
import {
|
||||||
|
NotePermissions,
|
||||||
|
userCanEdit,
|
||||||
|
WebsocketTransporter,
|
||||||
|
} from '@hedgedoc/commons';
|
||||||
import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets';
|
import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets';
|
||||||
import { IncomingMessage } from 'http';
|
import { IncomingMessage } from 'http';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
|
@ -77,10 +81,16 @@ export class WebsocketGateway implements OnGatewayConnection {
|
||||||
await this.realtimeNoteService.getOrCreateRealtimeNote(note);
|
await this.realtimeNoteService.getOrCreateRealtimeNote(note);
|
||||||
|
|
||||||
const websocketTransporter = new WebsocketTransporter();
|
const websocketTransporter = new WebsocketTransporter();
|
||||||
|
const permissions = await this.noteService.toNotePermissionsDto(note);
|
||||||
|
const acceptEdits: boolean = userCanEdit(
|
||||||
|
permissions as NotePermissions,
|
||||||
|
user?.username,
|
||||||
|
);
|
||||||
const connection = new RealtimeConnection(
|
const connection = new RealtimeConnection(
|
||||||
websocketTransporter,
|
websocketTransporter,
|
||||||
user,
|
user,
|
||||||
realtimeNote,
|
realtimeNote,
|
||||||
|
acceptEdits,
|
||||||
);
|
);
|
||||||
websocketTransporter.setWebsocket(clientSocket);
|
websocketTransporter.setWebsocket(clientSocket);
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,8 @@ export {
|
||||||
MissingTrailingSlashError,
|
MissingTrailingSlashError,
|
||||||
WrongProtocolError
|
WrongProtocolError
|
||||||
} from './utils/errors.js'
|
} from './utils/errors.js'
|
||||||
|
export * from './utils/permissions.js'
|
||||||
|
export * from './utils/permissions.types.js'
|
||||||
|
|
||||||
export * from './y-doc-sync/y-doc-sync-client-adapter.js'
|
export * from './y-doc-sync/y-doc-sync-client-adapter.js'
|
||||||
export * from './y-doc-sync/y-doc-sync-server-adapter.js'
|
export * from './y-doc-sync/y-doc-sync-server-adapter.js'
|
||||||
|
|
87
commons/src/utils/permissions.spec.ts
Normal file
87
commons/src/utils/permissions.spec.ts
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import { userCanEdit, userIsOwner } from './permissions.js'
|
||||||
|
import { NotePermissions, SpecialGroup } from './permissions.types.js'
|
||||||
|
import { describe, expect, it } from '@jest/globals'
|
||||||
|
|
||||||
|
describe('Permissions', () => {
|
||||||
|
const testPermissions: NotePermissions = {
|
||||||
|
owner: 'owner',
|
||||||
|
sharedToUsers: [
|
||||||
|
{
|
||||||
|
username: 'logged_in',
|
||||||
|
canEdit: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sharedToGroups: [
|
||||||
|
{
|
||||||
|
groupName: SpecialGroup.EVERYONE,
|
||||||
|
canEdit: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
groupName: SpecialGroup.LOGGED_IN,
|
||||||
|
canEdit: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
describe('userIsOwner', () => {
|
||||||
|
it('returns true, if user is owner', () => {
|
||||||
|
expect(userIsOwner(testPermissions, 'owner')).toBeTruthy()
|
||||||
|
})
|
||||||
|
it('returns false, if user is not ownerr', () => {
|
||||||
|
expect(userIsOwner(testPermissions, 'not_owner')).toBeFalsy()
|
||||||
|
})
|
||||||
|
it('returns false, if user is undefined', () => {
|
||||||
|
expect(userIsOwner(testPermissions, undefined)).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('userCanEdit', () => {
|
||||||
|
it('returns true, if user is owner', () => {
|
||||||
|
expect(userCanEdit(testPermissions, 'owner')).toBeTruthy()
|
||||||
|
})
|
||||||
|
it('returns true, if user is logged in and this is user specifically may edit', () => {
|
||||||
|
expect(
|
||||||
|
userCanEdit({ ...testPermissions, sharedToGroups: [] }, 'logged_in')
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
it('returns true, if user is logged in and loggedIn users may edit', () => {
|
||||||
|
expect(
|
||||||
|
userCanEdit({ ...testPermissions, sharedToUsers: [] }, 'logged_in')
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
it('returns true, if user is guest and guests are allowed to edit', () => {
|
||||||
|
expect(
|
||||||
|
userCanEdit({ ...testPermissions, sharedToUsers: [] }, undefined)
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
it('returns false, if user is logged in and loggedIn users may not edit', () => {
|
||||||
|
expect(
|
||||||
|
userCanEdit(
|
||||||
|
{ ...testPermissions, sharedToUsers: [], sharedToGroups: [] },
|
||||||
|
'logged_in'
|
||||||
|
)
|
||||||
|
).toBeFalsy()
|
||||||
|
})
|
||||||
|
it('returns false, if user is guest and guests are not allowed to edit', () => {
|
||||||
|
expect(
|
||||||
|
userCanEdit(
|
||||||
|
{
|
||||||
|
...testPermissions,
|
||||||
|
sharedToUsers: [],
|
||||||
|
sharedToGroups: [
|
||||||
|
{
|
||||||
|
groupName: SpecialGroup.LOGGED_IN,
|
||||||
|
canEdit: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
51
commons/src/utils/permissions.ts
Normal file
51
commons/src/utils/permissions.ts
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import { NotePermissions, SpecialGroup } from './permissions.types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given user is the owner of a note.
|
||||||
|
*
|
||||||
|
* @param permissions The permissions of the note to check
|
||||||
|
* @param user The username of the user
|
||||||
|
* @return True if the user is the owner of the note
|
||||||
|
*/
|
||||||
|
export const userIsOwner = (
|
||||||
|
permissions: NotePermissions,
|
||||||
|
user?: string
|
||||||
|
): boolean => {
|
||||||
|
return !!user && permissions.owner === user
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given user may edit a note.
|
||||||
|
*
|
||||||
|
* @param permissions The permissions of the note to check
|
||||||
|
* @param user The username of the user
|
||||||
|
* @return True if the user has the permission to edit the note
|
||||||
|
*/
|
||||||
|
export const userCanEdit = (
|
||||||
|
permissions: NotePermissions,
|
||||||
|
user?: string
|
||||||
|
): boolean => {
|
||||||
|
const isOwner = userIsOwner(permissions, user)
|
||||||
|
const mayWriteViaUserPermission = permissions.sharedToUsers.some(
|
||||||
|
(value) => value.canEdit && value.username === user
|
||||||
|
)
|
||||||
|
const mayWriteViaGroupPermission =
|
||||||
|
!!user &&
|
||||||
|
permissions.sharedToGroups.some(
|
||||||
|
(value) => value.groupName === SpecialGroup.LOGGED_IN && value.canEdit
|
||||||
|
)
|
||||||
|
const everyoneMayWriteViaGroupPermission = permissions.sharedToGroups.some(
|
||||||
|
(value) => value.groupName === SpecialGroup.EVERYONE && value.canEdit
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
isOwner ||
|
||||||
|
mayWriteViaUserPermission ||
|
||||||
|
mayWriteViaGroupPermission ||
|
||||||
|
everyoneMayWriteViaGroupPermission
|
||||||
|
)
|
||||||
|
}
|
31
commons/src/utils/permissions.types.ts
Normal file
31
commons/src/utils/permissions.types.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface NotePermissions {
|
||||||
|
owner: string | null
|
||||||
|
sharedToUsers: NoteUserPermissionEntry[]
|
||||||
|
sharedToGroups: NoteGroupPermissionEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteUserPermissionEntry {
|
||||||
|
username: string
|
||||||
|
canEdit: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteGroupPermissionEntry {
|
||||||
|
groupName: string
|
||||||
|
canEdit: boolean
|
||||||
|
}
|
||||||
|
export enum AccessLevel {
|
||||||
|
NONE,
|
||||||
|
READ_ONLY,
|
||||||
|
WRITEABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SpecialGroup {
|
||||||
|
EVERYONE = '_EVERYONE',
|
||||||
|
LOGGED_IN = '_LOGGED_IN'
|
||||||
|
}
|
|
@ -104,12 +104,14 @@ describe('message transporter', () => {
|
||||||
|
|
||||||
const yDocSyncAdapterServerTo1 = new YDocSyncServerAdapter(
|
const yDocSyncAdapterServerTo1 = new YDocSyncServerAdapter(
|
||||||
transporterServerTo1,
|
transporterServerTo1,
|
||||||
docServer
|
docServer,
|
||||||
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
const yDocSyncAdapterServerTo2 = new YDocSyncServerAdapter(
|
const yDocSyncAdapterServerTo2 = new YDocSyncServerAdapter(
|
||||||
transporterServerTo2,
|
transporterServerTo2,
|
||||||
docServer
|
docServer,
|
||||||
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
const waitForClient1Sync = new Promise<void>((resolve) => {
|
const waitForClient1Sync = new Promise<void>((resolve) => {
|
||||||
|
|
|
@ -28,7 +28,7 @@ export abstract class YDocSyncAdapter {
|
||||||
this.yDocUpdateListener = doc.on(
|
this.yDocUpdateListener = doc.on(
|
||||||
'update',
|
'update',
|
||||||
(update, origin) => {
|
(update, origin) => {
|
||||||
this.processDocUpdate(update, origin)
|
this.distributeDocUpdate(update, origin)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
objectify: true
|
objectify: true
|
||||||
|
@ -92,7 +92,7 @@ export abstract class YDocSyncAdapter {
|
||||||
|
|
||||||
const noteContentUpdateListener = this.messageTransporter.on(
|
const noteContentUpdateListener = this.messageTransporter.on(
|
||||||
MessageType.NOTE_CONTENT_UPDATE,
|
MessageType.NOTE_CONTENT_UPDATE,
|
||||||
(payload) => this.doc.applyUpdate(payload.payload, this),
|
(payload) => this.applyIncomingUpdatePayload(payload.payload),
|
||||||
{ objectify: true }
|
{ objectify: true }
|
||||||
) as Listener
|
) as Listener
|
||||||
|
|
||||||
|
@ -103,7 +103,11 @@ export abstract class YDocSyncAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private processDocUpdate(update: number[], origin: unknown): void {
|
protected applyIncomingUpdatePayload(update: number[]): void {
|
||||||
|
this.doc.applyUpdate(update, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private distributeDocUpdate(update: number[], origin: unknown): void {
|
||||||
if (!this.isSynced() || origin === this) {
|
if (!this.isSynced() || origin === this) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,9 +10,17 @@ import { YDocSyncAdapter } from './y-doc-sync-adapter.js'
|
||||||
export class YDocSyncServerAdapter extends YDocSyncAdapter {
|
export class YDocSyncServerAdapter extends YDocSyncAdapter {
|
||||||
constructor(
|
constructor(
|
||||||
readonly messageTransporter: MessageTransporter,
|
readonly messageTransporter: MessageTransporter,
|
||||||
readonly doc: RealtimeDoc
|
readonly doc: RealtimeDoc,
|
||||||
|
readonly acceptEdits: boolean
|
||||||
) {
|
) {
|
||||||
super(messageTransporter, doc)
|
super(messageTransporter, doc)
|
||||||
this.markAsSynced()
|
this.markAsSynced()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected applyIncomingUpdatePayload(update: number[]): void {
|
||||||
|
if (!this.acceptEdits) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
super.applyIncomingUpdatePayload(update)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { Alias } from '../alias/types'
|
import type { Alias } from '../alias/types'
|
||||||
|
import type { NotePermissions } from '@hedgedoc/commons'
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
content: string
|
content: string
|
||||||
|
@ -35,22 +36,6 @@ export interface NoteEdit {
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotePermissions {
|
|
||||||
owner: string | null
|
|
||||||
sharedToUsers: NoteUserPermissionEntry[]
|
|
||||||
sharedToGroups: NoteGroupPermissionEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteUserPermissionEntry {
|
|
||||||
username: string
|
|
||||||
canEdit: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteGroupPermissionEntry {
|
|
||||||
groupName: string
|
|
||||||
canEdit: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteDeletionOptions {
|
export interface NoteDeletionOptions {
|
||||||
keepMedia: boolean
|
keepMedia: boolean
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-api-request-builder'
|
||||||
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
|
import { PutApiRequestBuilder } from '../common/api-request-builder/put-api-request-builder'
|
||||||
import type { NotePermissions } from '../notes/types'
|
|
||||||
import type { OwnerChangeDto, PermissionSetDto } from './types'
|
import type { OwnerChangeDto, PermissionSetDto } from './types'
|
||||||
|
import type { NotePermissions } from '@hedgedoc/commons'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the owner of a note.
|
* Sets the owner of a note.
|
||||||
|
|
|
@ -4,8 +4,9 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import * as AliasModule from '../../../../api/alias'
|
import * as AliasModule from '../../../../api/alias'
|
||||||
import * as useApplicationStateModule from '../../../../hooks/common/use-application-state'
|
|
||||||
import * as NoteDetailsReduxModule from '../../../../redux/note-details/methods'
|
import * as NoteDetailsReduxModule from '../../../../redux/note-details/methods'
|
||||||
|
import type { NoteDetails } from '../../../../redux/note-details/types/note-details'
|
||||||
|
import { mockNoteOwnership } from '../../../../test-utils/note-ownership'
|
||||||
import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n'
|
import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n'
|
||||||
import * as useUiNotificationsModule from '../../../notifications/ui-notification-boundary'
|
import * as useUiNotificationsModule from '../../../notifications/ui-notification-boundary'
|
||||||
import { AliasesAddForm } from './aliases-add-form'
|
import { AliasesAddForm } from './aliases-add-form'
|
||||||
|
@ -25,12 +26,12 @@ describe('AliasesAddForm', () => {
|
||||||
await mockI18n()
|
await mockI18n()
|
||||||
jest.spyOn(AliasModule, 'addAlias').mockImplementation(() => addPromise)
|
jest.spyOn(AliasModule, 'addAlias').mockImplementation(() => addPromise)
|
||||||
jest.spyOn(NoteDetailsReduxModule, 'updateMetadata').mockImplementation(() => Promise.resolve())
|
jest.spyOn(NoteDetailsReduxModule, 'updateMetadata').mockImplementation(() => Promise.resolve())
|
||||||
jest.spyOn(useApplicationStateModule, 'useApplicationState').mockReturnValue('mock-note')
|
|
||||||
jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({
|
jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({
|
||||||
showErrorNotification: jest.fn(),
|
showErrorNotification: jest.fn(),
|
||||||
dismissNotification: jest.fn(),
|
dismissNotification: jest.fn(),
|
||||||
dispatchUiNotification: jest.fn()
|
dispatchUiNotification: jest.fn()
|
||||||
})
|
})
|
||||||
|
mockNoteOwnership('test', 'test', { noteDetails: { id: 'mock-note' } as NoteDetails })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
import { UiIcon } from '../../../common/icons/ui-icon'
|
import { UiIcon } from '../../../common/icons/ui-icon'
|
||||||
import type { PermissionDisabledProps } from './permission-disabled.prop'
|
import type { PermissionDisabledProps } from './permission-disabled.prop'
|
||||||
import { AccessLevel } from './types'
|
import { AccessLevel } from '@hedgedoc/commons'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { Button, ToggleButtonGroup } from 'react-bootstrap'
|
import { Button, ToggleButtonGroup } from 'react-bootstrap'
|
||||||
import { Eye as IconEye } from 'react-bootstrap-icons'
|
import { Eye as IconEye } from 'react-bootstrap-icons'
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { setNotePermissionsFromServer } from '../../../../redux/note-details/met
|
||||||
import { IconButton } from '../../../common/icon-button/icon-button'
|
import { IconButton } from '../../../common/icon-button/icon-button'
|
||||||
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
|
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
|
||||||
import type { PermissionDisabledProps } from './permission-disabled.prop'
|
import type { PermissionDisabledProps } from './permission-disabled.prop'
|
||||||
import { AccessLevel, SpecialGroup } from './types'
|
import { AccessLevel, SpecialGroup } from '@hedgedoc/commons'
|
||||||
import React, { useCallback, useMemo } from 'react'
|
import React, { useCallback, useMemo } from 'react'
|
||||||
import { ToggleButtonGroup } from 'react-bootstrap'
|
import { ToggleButtonGroup } from 'react-bootstrap'
|
||||||
import { Eye as IconEye } from 'react-bootstrap-icons'
|
import { Eye as IconEye } from 'react-bootstrap-icons'
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { NoteUserPermissionEntry } from '../../../../api/notes/types'
|
|
||||||
import { removeUserPermission, setUserPermission } from '../../../../api/permissions'
|
import { removeUserPermission, setUserPermission } from '../../../../api/permissions'
|
||||||
import { getUser } from '../../../../api/users'
|
import { getUser } from '../../../../api/users'
|
||||||
import { useApplicationState } from '../../../../hooks/common/use-application-state'
|
import { useApplicationState } from '../../../../hooks/common/use-application-state'
|
||||||
|
@ -13,7 +12,8 @@ import { UserAvatarForUser } from '../../../common/user-avatar/user-avatar-for-u
|
||||||
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
|
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
|
||||||
import type { PermissionDisabledProps } from './permission-disabled.prop'
|
import type { PermissionDisabledProps } from './permission-disabled.prop'
|
||||||
import { PermissionEntryButtons, PermissionType } from './permission-entry-buttons'
|
import { PermissionEntryButtons, PermissionType } from './permission-entry-buttons'
|
||||||
import { AccessLevel } from './types'
|
import type { NoteUserPermissionEntry } from '@hedgedoc/commons'
|
||||||
|
import { AccessLevel } from '@hedgedoc/commons'
|
||||||
import React, { useCallback } from 'react'
|
import React, { useCallback } from 'react'
|
||||||
import { useAsync } from 'react-use'
|
import { useAsync } from 'react-use'
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { useApplicationState } from '../../../../hooks/common/use-application-st
|
||||||
import { useIsOwner } from '../../../../hooks/common/use-is-owner'
|
import { useIsOwner } from '../../../../hooks/common/use-is-owner'
|
||||||
import type { PermissionDisabledProps } from './permission-disabled.prop'
|
import type { PermissionDisabledProps } from './permission-disabled.prop'
|
||||||
import { PermissionEntrySpecialGroup } from './permission-entry-special-group'
|
import { PermissionEntrySpecialGroup } from './permission-entry-special-group'
|
||||||
import { AccessLevel, SpecialGroup } from './types'
|
import { AccessLevel, SpecialGroup } from '@hedgedoc/commons'
|
||||||
import React, { Fragment, useMemo } from 'react'
|
import React, { Fragment, useMemo } from 'react'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
export enum AccessLevel {
|
|
||||||
NONE,
|
|
||||||
READ_ONLY,
|
|
||||||
WRITEABLE
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum SpecialGroup {
|
|
||||||
EVERYONE = '_EVERYONE',
|
|
||||||
LOGGED_IN = '_LOGGED_IN'
|
|
||||||
}
|
|
|
@ -6,6 +6,7 @@
|
||||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||||
import { ORIGIN, useBaseUrl } from '../../../hooks/common/use-base-url'
|
import { ORIGIN, useBaseUrl } from '../../../hooks/common/use-base-url'
|
||||||
import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state'
|
import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state'
|
||||||
|
import { useMayEdit } from '../../../hooks/common/use-may-edit'
|
||||||
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||||
import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/base/code-block-markdown-extension/find-language-by-code-block-name'
|
import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/base/code-block-markdown-extension/find-language-by-code-block-name'
|
||||||
import type { ScrollProps } from '../synced-scroll/scroll-props'
|
import type { ScrollProps } from '../synced-scroll/scroll-props'
|
||||||
|
@ -130,6 +131,7 @@ export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, o
|
||||||
const darkModeActivated = useDarkModeState()
|
const darkModeActivated = useDarkModeState()
|
||||||
const editorOrigin = useBaseUrl(ORIGIN.EDITOR)
|
const editorOrigin = useBaseUrl(ORIGIN.EDITOR)
|
||||||
const isSynced = useApplicationState((state) => state.realtimeStatus.isSynced)
|
const isSynced = useApplicationState((state) => state.realtimeStatus.isSynced)
|
||||||
|
const mayEdit = useMayEdit()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = messageTransporter.doAsSoonAsConnected(() => messageTransporter.sendReady())
|
const listener = messageTransporter.doAsSoonAsConnected(() => messageTransporter.sendReady())
|
||||||
|
@ -144,11 +146,11 @@ export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, o
|
||||||
onTouchStart={onMakeScrollSource}
|
onTouchStart={onMakeScrollSource}
|
||||||
onMouseEnter={onMakeScrollSource}
|
onMouseEnter={onMakeScrollSource}
|
||||||
{...cypressId('editor-pane')}
|
{...cypressId('editor-pane')}
|
||||||
{...cypressAttribute('editor-ready', String(updateViewContextExtension !== null && isSynced))}>
|
{...cypressAttribute('editor-ready', String(updateViewContextExtension !== null && isSynced && mayEdit))}>
|
||||||
<MaxLengthWarning />
|
<MaxLengthWarning />
|
||||||
<ToolBar />
|
<ToolBar />
|
||||||
<ReactCodeMirror
|
<ReactCodeMirror
|
||||||
editable={updateViewContextExtension !== null && isSynced}
|
editable={updateViewContextExtension !== null && isSynced && mayEdit}
|
||||||
placeholder={t('editor.placeholder', { host: editorOrigin }) ?? ''}
|
placeholder={t('editor.placeholder', { host: editorOrigin }) ?? ''}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
width={'100%'}
|
width={'100%'}
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { useApplicationState } from './use-application-state'
|
import { useApplicationState } from './use-application-state'
|
||||||
|
import type { NotePermissions } from '@hedgedoc/commons'
|
||||||
|
import { userIsOwner } from '@hedgedoc/commons'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -12,8 +14,8 @@ import { useMemo } from 'react'
|
||||||
* @return True, if the current user is owner.
|
* @return True, if the current user is owner.
|
||||||
*/
|
*/
|
||||||
export const useIsOwner = (): boolean => {
|
export const useIsOwner = (): boolean => {
|
||||||
const owner = useApplicationState((state) => state.noteDetails.permissions.owner)
|
|
||||||
const me: string | undefined = useApplicationState((state) => state.user?.username)
|
const me: string | undefined = useApplicationState((state) => state.user?.username)
|
||||||
|
const permissions: NotePermissions = useApplicationState((state) => state.noteDetails.permissions)
|
||||||
|
|
||||||
return useMemo(() => !!me && owner === me, [owner, me])
|
return useMemo(() => userIsOwner(permissions, me), [permissions, me])
|
||||||
}
|
}
|
||||||
|
|
21
frontend/src/hooks/common/use-may-edit.ts
Normal file
21
frontend/src/hooks/common/use-may-edit.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import { useApplicationState } from './use-application-state'
|
||||||
|
import type { NotePermissions } from '@hedgedoc/commons'
|
||||||
|
import { userCanEdit } from '@hedgedoc/commons'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the current user is allowed to write to this note.
|
||||||
|
*
|
||||||
|
* @return True, if the current user is allowed to write.
|
||||||
|
*/
|
||||||
|
export const useMayEdit = (): boolean => {
|
||||||
|
const me: string | undefined = useApplicationState((state) => state.user?.username)
|
||||||
|
const permissions: NotePermissions = useApplicationState((state) => state.noteDetails.permissions)
|
||||||
|
|
||||||
|
return useMemo(() => userCanEdit(permissions, me), [permissions, me])
|
||||||
|
}
|
|
@ -1,11 +1,11 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { store } from '..'
|
import { store } from '..'
|
||||||
import { getNoteMetadata } from '../../api/notes'
|
import { getNoteMetadata } from '../../api/notes'
|
||||||
import type { Note, NotePermissions } from '../../api/notes/types'
|
import type { Note } from '../../api/notes/types'
|
||||||
import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||||
import type {
|
import type {
|
||||||
SetNoteDetailsFromServerAction,
|
SetNoteDetailsFromServerAction,
|
||||||
|
@ -16,6 +16,7 @@ import type {
|
||||||
UpdateNoteTitleByFirstHeadingAction
|
UpdateNoteTitleByFirstHeadingAction
|
||||||
} from './types'
|
} from './types'
|
||||||
import { NoteDetailsActionType } from './types'
|
import { NoteDetailsActionType } from './types'
|
||||||
|
import type { NotePermissions } from '@hedgedoc/commons'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the content of the current note, extracts and parses the frontmatter and extracts the markdown content part.
|
* Sets the content of the current note, extracts and parses the frontmatter and extracts the markdown content part.
|
||||||
|
|
|
@ -3,10 +3,10 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { NotePermissions } from '../../../api/notes/types'
|
|
||||||
import { initialState } from '../initial-state'
|
import { initialState } from '../initial-state'
|
||||||
import type { NoteDetails } from '../types/note-details'
|
import type { NoteDetails } from '../types/note-details'
|
||||||
import { buildStateFromServerPermissions } from './build-state-from-server-permissions'
|
import { buildStateFromServerPermissions } from './build-state-from-server-permissions'
|
||||||
|
import type { NotePermissions } from '@hedgedoc/commons'
|
||||||
|
|
||||||
describe('build state from server permissions', () => {
|
describe('build state from server permissions', () => {
|
||||||
it('creates a new state with the given permissions', () => {
|
it('creates a new state with the given permissions', () => {
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { NotePermissions } from '../../../api/notes/types'
|
|
||||||
import type { NoteDetails } from '../types/note-details'
|
import type { NoteDetails } from '../types/note-details'
|
||||||
|
import type { NotePermissions } from '@hedgedoc/commons'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the updated state from a given previous state and updated NotePermissions data.
|
* Builds the updated state from a given previous state and updated NotePermissions data.
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import type { Note, NoteMetadata, NotePermissions } from '../../api/notes/types'
|
import type { Note, NoteMetadata } from '../../api/notes/types'
|
||||||
import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||||
|
import type { NotePermissions } from '@hedgedoc/commons'
|
||||||
import type { Action } from 'redux'
|
import type { Action } from 'redux'
|
||||||
|
|
||||||
export enum NoteDetailsActionType {
|
export enum NoteDetailsActionType {
|
||||||
|
|
|
@ -7,15 +7,22 @@ import * as useApplicationStateModule from '../hooks/common/use-application-stat
|
||||||
import type { ApplicationState } from '../redux/application-state'
|
import type { ApplicationState } from '../redux/application-state'
|
||||||
|
|
||||||
jest.mock('../hooks/common/use-application-state')
|
jest.mock('../hooks/common/use-application-state')
|
||||||
export const mockNoteOwnership = (ownUsername: string, noteOwner: string) => {
|
export const mockNoteOwnership = (
|
||||||
|
ownUsername: string,
|
||||||
|
noteOwner: string,
|
||||||
|
additionalState?: Partial<ApplicationState>
|
||||||
|
) => {
|
||||||
jest.spyOn(useApplicationStateModule, 'useApplicationState').mockImplementation((fn) => {
|
jest.spyOn(useApplicationStateModule, 'useApplicationState').mockImplementation((fn) => {
|
||||||
return fn({
|
return fn({
|
||||||
|
...additionalState,
|
||||||
noteDetails: {
|
noteDetails: {
|
||||||
|
...additionalState?.noteDetails,
|
||||||
permissions: {
|
permissions: {
|
||||||
owner: noteOwner
|
owner: noteOwner
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
|
...additionalState?.user,
|
||||||
username: ownUsername
|
username: ownUsername
|
||||||
}
|
}
|
||||||
} as ApplicationState)
|
} as ApplicationState)
|
||||||
|
|
Loading…
Reference in a new issue