mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-06-02 07:59:56 -04:00
fix(backend): correct user permissions, photo urls, events, etc.
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
parent
431e3df61b
commit
327cc1f925
7 changed files with 76 additions and 31 deletions
|
@ -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) {
|
||||||
|
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue