fix(backend): correct user permissions, photo urls, events, etc.

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2025-05-20 11:45:35 +00:00
parent 431e3df61b
commit 327cc1f925
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
7 changed files with 76 additions and 31 deletions

View file

@ -185,11 +185,11 @@ export class NotesController {
@UseInterceptors(GetNoteIdInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
async setUserPermission( async setUserPermission(
@RequestUserId() userId: number,
@RequestNoteId() noteId: number, @RequestNoteId() noteId: number,
@Param('username') username: NoteUserPermissionUpdateDto['username'], @Param('username') username: NoteUserPermissionUpdateDto['username'],
@Body('canEdit') canEdit: NoteUserPermissionUpdateDto['canEdit'], @Body('canEdit') canEdit: NoteUserPermissionUpdateDto['canEdit'],
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
const userId = await this.userService.getUserIdByUsername(username);
await this.permissionService.setUserPermission(noteId, userId, canEdit); await this.permissionService.setUserPermission(noteId, userId, canEdit);
return await this.noteService.toNotePermissionsDto(noteId); return await this.noteService.toNotePermissionsDto(noteId);
} }
@ -198,12 +198,11 @@ export class NotesController {
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@Delete(':noteAlias/metadata/permissions/users/:username') @Delete(':noteAlias/metadata/permissions/users/:username')
async removeUserPermission( async removeUserPermission(
@RequestUserId() userId: number,
@RequestNoteId() noteId: number, @RequestNoteId() noteId: number,
@Param('username') username: NoteUserPermissionEntryDto['username'], @Param('username') username: NoteUserPermissionEntryDto['username'],
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
// TODO Fix this removing wrong user permission!
try { try {
const userId = await this.userService.getUserIdByUsername(username);
await this.permissionService.removeUserPermission(noteId, userId); await this.permissionService.removeUserPermission(noteId, userId);
return await this.noteService.toNotePermissionsDto(noteId); return await this.noteService.toNotePermissionsDto(noteId);
} catch (e) { } catch (e) {

View file

@ -34,7 +34,7 @@ export class UsersController {
const userExists = await this.userService.isUsernameTaken( const userExists = await this.userService.isUsernameTaken(
usernameCheck.username, usernameCheck.username,
); );
// TODO Check if username is blocked // TODO Check if username is blocked (https://github.com/hedgedoc/hedgedoc/issues/5794)
return { usernameAvailable: !userExists }; return { usernameAvailable: !userExists };
} }

View file

@ -109,8 +109,9 @@ export class LdapService {
username: username, username: username,
id: userInfo[ldapConfig.userIdField], id: userInfo[ldapConfig.userIdField],
displayName: userInfo[ldapConfig.displayNameField] ?? username, displayName: userInfo[ldapConfig.displayNameField] ?? username,
photoUrl: null, // TODO LDAP stores images as binaries, photoUrl: null,
// we need to convert them into a data-URL or alike // TODO LDAP stores images as binaries, we need to upload them to the media backend
// https://github.com/hedgedoc/hedgedoc/issues/5032
}); });
}, },
); );

View file

@ -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 * 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. * noteId: The id of the {@link Note}, which is being deleted.
*/ */
DELETION = 'note.deletion', 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 { export interface NoteEventMap extends EventMap {
[NoteEvent.PERMISSION_CHANGE]: (noteId: number) => void; [NoteEvent.PERMISSION_CHANGE]: (noteId: number) => void;
[NoteEvent.DELETION]: (noteId: number) => void;
[NoteEvent.CLOSE_REALTIME]: (noteId: number) => void;
} }

View file

@ -43,7 +43,7 @@ import {
MaximumDocumentLengthExceededError, MaximumDocumentLengthExceededError,
NotInDBError, NotInDBError,
} from '../errors/errors'; } from '../errors/errors';
import { NoteEventMap } from '../events'; import { NoteEvent, NoteEventMap } from '../events';
import { GroupsService } from '../groups/groups.service'; import { GroupsService } from '../groups/groups.service';
import { ConsoleLoggerService } from '../logger/console-logger.service'; import { ConsoleLoggerService } from '../logger/console-logger.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
@ -265,14 +265,13 @@ export class NoteService {
* @throws {NotInDBError} if there is no note with this id * @throws {NotInDBError} if there is no note with this id
*/ */
async deleteNote(noteId: Note[FieldNameNote.id]): Promise<void> { async deleteNote(noteId: Note[FieldNameNote.id]): Promise<void> {
// TODO Disconnect realtime clients first this.eventEmitter.emit(NoteEvent.DELETION, noteId);
const numberOfDeletedNotes = await this.knex(TableNote) const numberOfDeletedNotes = await this.knex(TableNote)
.where(FieldNameNote.id, noteId) .where(FieldNameNote.id, noteId)
.delete(); .delete();
if (numberOfDeletedNotes === 0) { if (numberOfDeletedNotes === 0) {
throw new NotInDBError(`There is no note with the to delete.`); 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 * @throws {NotInDBError} there is no note with this id or aliases
*/ */
async updateNote(noteId: number, noteContent: string): Promise<void> { async updateNote(noteId: number, noteContent: string): Promise<void> {
// TODO Disconnect realtime clients first this.eventEmitter.emit(NoteEvent.CLOSE_REALTIME, noteId);
await this.revisionsService.createRevision(noteId, noteContent); await this.revisionsService.createRevision(noteId, noteContent);
// TODO Reload realtime note
} }
/** /**

View file

@ -151,4 +151,13 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
realtimeNote.announceNoteDeletion(); realtimeNote.announceNoteDeletion();
} }
} }
@OnEvent(NoteEvent.CLOSE_REALTIME)
public closeRealtimeNote(noteId: number): void {
const realtimeNote = this.realtimeNoteStore.find(noteId);
if (realtimeNote) {
this.saveRealtimeNote(realtimeNote);
realtimeNote.destroy();
}
}
} }

View file

@ -16,6 +16,7 @@ import {
User, User,
} from '@hedgedoc/database'; } from '@hedgedoc/database';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { InjectConnection } from 'nest-knexjs'; import { InjectConnection } from 'nest-knexjs';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -68,16 +69,19 @@ export class UsersService {
[FieldNameUser.username]: username, [FieldNameUser.username]: username,
[FieldNameUser.displayName]: displayName, [FieldNameUser.displayName]: displayName,
[FieldNameUser.email]: email ?? null, [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 // TODO Use generatePhotoUrl method to generate a random avatar image
[FieldNameUser.guestUuid]: null, [FieldNameUser.guestUuid]: null,
[FieldNameUser.authorStyle]: 0, [FieldNameUser.authorStyle]: this.generateAuthorStyleIndex(username),
// FIXME Set unique authorStyle per user
}, },
[FieldNameUser.id], [FieldNameUser.id],
); );
if (newUsers.length !== 1) { if (newUsers.length !== 1) {
throw new Error(); throw new Error('User was not added to the database');
} }
return newUsers[0][FieldNameUser.id]; return newUsers[0][FieldNameUser.id];
} catch { } catch {
@ -105,8 +109,7 @@ export class UsersService {
[FieldNameUser.email]: null, [FieldNameUser.email]: null,
[FieldNameUser.photoUrl]: null, [FieldNameUser.photoUrl]: null,
[FieldNameUser.guestUuid]: uuid, [FieldNameUser.guestUuid]: uuid,
[FieldNameUser.authorStyle]: 0, [FieldNameUser.authorStyle]: this.generateAuthorStyleIndex(uuid),
// FIXME Set unique authorStyle per user
}, },
[FieldNameUser.id], [FieldNameUser.id],
); );
@ -228,7 +231,7 @@ export class UsersService {
* @return The found user object * @return The found user object
* @throws {NotInDBError} if the user could not be found * @throws {NotInDBError} if the user could not be found
*/ */
async getUserIdByUsername(username: string): Promise<User[FieldNameUser.id]> { async getUserIdByUsername(username: string): Promise<number> {
const userId = await this.knex(TableUser) const userId = await this.knex(TableUser)
.select(FieldNameUser.id) .select(FieldNameUser.id)
.where(FieldNameUser.username, username) .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 * @param username The username to use as a seed when generating a random avatar
* @return A URL to the user's profile picture. If the user has no photo and libravatar support is enabled, * @param email The email address of the user for using livbravatar if configured
* a URL to that is returned. Otherwise, undefined is returned to indicate that the frontend needs to generate * @param photoUrl The user-provided photo URL
* a random avatar image based on the username. * @return A URL to the user's profile picture.
*/ */
getPhotoUrl(user: User): string | undefined { private generatePhotoUrl(
if (user[FieldNameUser.photoUrl]) { username: string,
return user[FieldNameUser.photoUrl]; email: string | null,
} else { photoUrl: string | null,
// TODO If libravatar is enabled and the user has an email address, use it to fetch the profile picture from there ): string {
// Otherwise return undefined to let the frontend generate a random avatar image (#5010) if (photoUrl && URL.canParse(photoUrl)) {
return undefined; 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);
} }
/** /**