From 327cc1f925723c0fe00692a27a94ee3e00c89cff Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Tue, 20 May 2025 11:45:35 +0000 Subject: [PATCH] fix(backend): correct user permissions, photo urls, events, etc. Signed-off-by: Erik Michelson --- .../src/api/private/notes/notes.controller.ts | 5 +- .../src/api/private/users/users.controller.ts | 2 +- backend/src/auth/ldap/ldap.service.ts | 5 +- backend/src/events.ts | 11 ++- backend/src/notes/note.service.ts | 8 +-- .../realtime-note/realtime-note.service.ts | 9 +++ backend/src/users/users.service.ts | 67 +++++++++++++------ 7 files changed, 76 insertions(+), 31 deletions(-) diff --git a/backend/src/api/private/notes/notes.controller.ts b/backend/src/api/private/notes/notes.controller.ts index c9dffd757..60e15d5f1 100644 --- a/backend/src/api/private/notes/notes.controller.ts +++ b/backend/src/api/private/notes/notes.controller.ts @@ -185,11 +185,11 @@ export class NotesController { @UseInterceptors(GetNoteIdInterceptor) @RequirePermission(RequiredPermission.OWNER) async setUserPermission( - @RequestUserId() userId: number, @RequestNoteId() noteId: number, @Param('username') username: NoteUserPermissionUpdateDto['username'], @Body('canEdit') canEdit: NoteUserPermissionUpdateDto['canEdit'], ): Promise { + const userId = await this.userService.getUserIdByUsername(username); await this.permissionService.setUserPermission(noteId, userId, canEdit); return await this.noteService.toNotePermissionsDto(noteId); } @@ -198,12 +198,11 @@ export class NotesController { @RequirePermission(RequiredPermission.OWNER) @Delete(':noteAlias/metadata/permissions/users/:username') async removeUserPermission( - @RequestUserId() userId: number, @RequestNoteId() noteId: number, @Param('username') username: NoteUserPermissionEntryDto['username'], ): Promise { - // TODO Fix this removing wrong user permission! try { + const userId = await this.userService.getUserIdByUsername(username); await this.permissionService.removeUserPermission(noteId, userId); return await this.noteService.toNotePermissionsDto(noteId); } catch (e) { diff --git a/backend/src/api/private/users/users.controller.ts b/backend/src/api/private/users/users.controller.ts index 24aabe6f9..1e1f9124c 100644 --- a/backend/src/api/private/users/users.controller.ts +++ b/backend/src/api/private/users/users.controller.ts @@ -34,7 +34,7 @@ export class UsersController { const userExists = await this.userService.isUsernameTaken( usernameCheck.username, ); - // TODO Check if username is blocked + // TODO Check if username is blocked (https://github.com/hedgedoc/hedgedoc/issues/5794) return { usernameAvailable: !userExists }; } diff --git a/backend/src/auth/ldap/ldap.service.ts b/backend/src/auth/ldap/ldap.service.ts index 4603868c4..f4df0d84e 100644 --- a/backend/src/auth/ldap/ldap.service.ts +++ b/backend/src/auth/ldap/ldap.service.ts @@ -109,8 +109,9 @@ export class LdapService { username: username, id: userInfo[ldapConfig.userIdField], displayName: userInfo[ldapConfig.displayNameField] ?? username, - photoUrl: null, // TODO LDAP stores images as binaries, - // we need to convert them into a data-URL or alike + photoUrl: null, + // TODO LDAP stores images as binaries, we need to upload them to the media backend + // https://github.com/hedgedoc/hedgedoc/issues/5032 }); }, ); diff --git a/backend/src/events.ts b/backend/src/events.ts index 0fcf162c4..26a545667 100644 --- a/backend/src/events.ts +++ b/backend/src/events.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -29,8 +29,17 @@ export enum NoteEvent { * noteId: The id of the {@link Note}, which is being deleted. */ DELETION = 'note.deletion', + + /** + * Event triggered when the realtime note needs to be closed, e.g. when external updates are made to the note. + * Payload: + * noteId: The id of the {@link Note}, which should be closed. + */ + CLOSE_REALTIME = 'note.close_realtime', } export interface NoteEventMap extends EventMap { [NoteEvent.PERMISSION_CHANGE]: (noteId: number) => void; + [NoteEvent.DELETION]: (noteId: number) => void; + [NoteEvent.CLOSE_REALTIME]: (noteId: number) => void; } diff --git a/backend/src/notes/note.service.ts b/backend/src/notes/note.service.ts index 0a8f7bf5e..a3f4c2f9c 100644 --- a/backend/src/notes/note.service.ts +++ b/backend/src/notes/note.service.ts @@ -43,7 +43,7 @@ import { MaximumDocumentLengthExceededError, NotInDBError, } from '../errors/errors'; -import { NoteEventMap } from '../events'; +import { NoteEvent, NoteEventMap } from '../events'; import { GroupsService } from '../groups/groups.service'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { PermissionService } from '../permissions/permission.service'; @@ -265,14 +265,13 @@ export class NoteService { * @throws {NotInDBError} if there is no note with this id */ async deleteNote(noteId: Note[FieldNameNote.id]): Promise { - // TODO Disconnect realtime clients first + this.eventEmitter.emit(NoteEvent.DELETION, noteId); const numberOfDeletedNotes = await this.knex(TableNote) .where(FieldNameNote.id, noteId) .delete(); if (numberOfDeletedNotes === 0) { throw new NotInDBError(`There is no note with the to delete.`); } - // TODO Message realtime clients } /** @@ -285,9 +284,8 @@ export class NoteService { * @throws {NotInDBError} there is no note with this id or aliases */ async updateNote(noteId: number, noteContent: string): Promise { - // TODO Disconnect realtime clients first + this.eventEmitter.emit(NoteEvent.CLOSE_REALTIME, noteId); await this.revisionsService.createRevision(noteId, noteContent); - // TODO Reload realtime note } /** diff --git a/backend/src/realtime/realtime-note/realtime-note.service.ts b/backend/src/realtime/realtime-note/realtime-note.service.ts index 800c1d363..588cbf02e 100644 --- a/backend/src/realtime/realtime-note/realtime-note.service.ts +++ b/backend/src/realtime/realtime-note/realtime-note.service.ts @@ -151,4 +151,13 @@ export class RealtimeNoteService implements BeforeApplicationShutdown { realtimeNote.announceNoteDeletion(); } } + + @OnEvent(NoteEvent.CLOSE_REALTIME) + public closeRealtimeNote(noteId: number): void { + const realtimeNote = this.realtimeNoteStore.find(noteId); + if (realtimeNote) { + this.saveRealtimeNote(realtimeNote); + realtimeNote.destroy(); + } + } } diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 2497cb6e2..096c4f8c4 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -16,6 +16,7 @@ import { User, } from '@hedgedoc/database'; import { BadRequestException, Injectable } from '@nestjs/common'; +import { createHash } from 'crypto'; import { Knex } from 'knex'; import { InjectConnection } from 'nest-knexjs'; import { v4 as uuidv4 } from 'uuid'; @@ -68,16 +69,19 @@ export class UsersService { [FieldNameUser.username]: username, [FieldNameUser.displayName]: displayName, [FieldNameUser.email]: email ?? null, - [FieldNameUser.photoUrl]: photoUrl ?? null, + [FieldNameUser.photoUrl]: this.generatePhotoUrl( + username, + email, + photoUrl, + ), // TODO Use generatePhotoUrl method to generate a random avatar image [FieldNameUser.guestUuid]: null, - [FieldNameUser.authorStyle]: 0, - // FIXME Set unique authorStyle per user + [FieldNameUser.authorStyle]: this.generateAuthorStyleIndex(username), }, [FieldNameUser.id], ); if (newUsers.length !== 1) { - throw new Error(); + throw new Error('User was not added to the database'); } return newUsers[0][FieldNameUser.id]; } catch { @@ -105,8 +109,7 @@ export class UsersService { [FieldNameUser.email]: null, [FieldNameUser.photoUrl]: null, [FieldNameUser.guestUuid]: uuid, - [FieldNameUser.authorStyle]: 0, - // FIXME Set unique authorStyle per user + [FieldNameUser.authorStyle]: this.generateAuthorStyleIndex(uuid), }, [FieldNameUser.id], ); @@ -228,7 +231,7 @@ export class UsersService { * @return The found user object * @throws {NotInDBError} if the user could not be found */ - async getUserIdByUsername(username: string): Promise { + async getUserIdByUsername(username: string): Promise { const userId = await this.knex(TableUser) .select(FieldNameUser.id) .where(FieldNameUser.username, username) @@ -307,21 +310,47 @@ export class UsersService { } /** - * Extract the photoUrl of the user or falls back to libravatar if enabled + * Generates the photo url for a user. + * When a valid photoUrl is provided, it will be used. + * When an email address is provided and libravatar is enabled, this will be used. + * Otherwise, a random image will be generated based on the username. * - * @param user The user of which to get the photo url - * @return A URL to the user's profile picture. If the user has no photo and libravatar support is enabled, - * a URL to that is returned. Otherwise, undefined is returned to indicate that the frontend needs to generate - * a random avatar image based on the username. + * @param username The username to use as a seed when generating a random avatar + * @param email The email address of the user for using livbravatar if configured + * @param photoUrl The user-provided photo URL + * @return A URL to the user's profile picture. */ - getPhotoUrl(user: User): string | undefined { - if (user[FieldNameUser.photoUrl]) { - return user[FieldNameUser.photoUrl]; - } else { - // TODO If libravatar is enabled and the user has an email address, use it to fetch the profile picture from there - // Otherwise return undefined to let the frontend generate a random avatar image (#5010) - return undefined; + private generatePhotoUrl( + username: string, + email: string | null, + photoUrl: string | null, + ): string { + if (photoUrl && URL.canParse(photoUrl)) { + return photoUrl; } + if (email && email.length > 0) { + // TODO Add config option to enable or disable libravatar + const hash = createHash('sha256') + .update(email.toLowerCase()) + .digest('hex'); + return `https://seccdn.libravatar.org/avatar/${hash}`; + } + // TODO Generate random fallback image (data URL) with the username as seed + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdj+L+E8T8ABu4CpDyuE+YAAAAASUVORK5CYII='; + } + + /** + * Creates a random author style index based on a hashing of the username + * + * @param username The username is used as input for the hash + * @return An index between 0 and 8 (including 0 and 8) + */ + private generateAuthorStyleIndex(username: string): number { + let hash = 0; + for (let i = 0; i < username.length; i++) { + hash = username.charCodeAt(i) + ((hash << 5) - hash); + } + return Math.abs(hash % 9); } /**