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:
Philip Molares 2023-03-26 14:51:18 +02:00 committed by Tilman Vatteroth
parent 24f1b2a361
commit c2f41118b6
27 changed files with 287 additions and 66 deletions

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

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

View 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()
})
})
})

View 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
)
}

View 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'
}

View file

@ -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) => {

View file

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

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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', () => {

View file

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

View file

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

View file

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