mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-17 08:34:54 -04:00
fix(repository): Move backend code into subdirectory
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
86584e705f
commit
bf30cbcf48
272 changed files with 87 additions and 67 deletions
25
backend/src/notes/alias-create.dto.ts
Normal file
25
backend/src/notes/alias-create.dto.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
|
||||
export class AliasCreateDto extends BaseDto {
|
||||
/**
|
||||
* The note id or alias, which identifies the note the alias should be added to
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
noteIdOrAlias: string;
|
||||
|
||||
/**
|
||||
* The new alias
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
newAlias: string;
|
||||
}
|
18
backend/src/notes/alias-update.dto.ts
Normal file
18
backend/src/notes/alias-update.dto.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
|
||||
export class AliasUpdateDto extends BaseDto {
|
||||
/**
|
||||
* Whether the alias should become the primary alias or not
|
||||
*/
|
||||
@IsBoolean()
|
||||
@ApiProperty()
|
||||
primaryAlias: boolean;
|
||||
}
|
32
backend/src/notes/alias.dto.ts
Normal file
32
backend/src/notes/alias.dto.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsString } from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
|
||||
export class AliasDto extends BaseDto {
|
||||
/**
|
||||
* The name of the alias
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Is the alias the primary alias or not
|
||||
*/
|
||||
@IsBoolean()
|
||||
@ApiProperty()
|
||||
primaryAlias: boolean;
|
||||
|
||||
/**
|
||||
* The public id of the note the alias is associated with
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
noteId: string;
|
||||
}
|
64
backend/src/notes/alias.entity.ts
Normal file
64
backend/src/notes/alias.entity.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Note } from './note.entity';
|
||||
import { PrimaryValueTransformer } from './primary.value-transformer';
|
||||
|
||||
@Entity()
|
||||
@Unique('Only one primary alias per note', ['note', 'primary'])
|
||||
export class Alias {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* the actual alias
|
||||
*/
|
||||
@Column({
|
||||
nullable: false,
|
||||
unique: true,
|
||||
})
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Is this alias the primary alias, by which people access the note?
|
||||
*/
|
||||
@Column({
|
||||
/*
|
||||
Because of the @Unique at the top of this entity, this field must be saved as null instead of false in the DB.
|
||||
If a non-primary alias would be saved with `primary: false` it would only be possible to have one non-primary and one primary alias.
|
||||
But a nullable field does not have such problems.
|
||||
This way the DB keeps track that one note really only has one primary alias.
|
||||
*/
|
||||
comment:
|
||||
'This field tells you if this is the primary alias of the note. If this field is null, that means this alias is not primary.',
|
||||
nullable: true,
|
||||
transformer: new PrimaryValueTransformer(),
|
||||
})
|
||||
primary: boolean;
|
||||
|
||||
@ManyToOne((_) => Note, (note) => note.aliases, {
|
||||
onDelete: 'CASCADE', // This deletes the Alias, when the associated Note is deleted
|
||||
})
|
||||
note: Promise<Note>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
static create(name: string, note: Note, primary: boolean): Omit<Alias, 'id'> {
|
||||
const alias = new Alias();
|
||||
alias.name = name;
|
||||
alias.primary = primary;
|
||||
alias.note = Promise.resolve(note);
|
||||
return alias;
|
||||
}
|
||||
}
|
308
backend/src/notes/alias.service.spec.ts
Normal file
308
backend/src/notes/alias.service.spec.ts
Normal file
|
@ -0,0 +1,308 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
|
||||
import { AuthToken } from '../auth/auth-token.entity';
|
||||
import { Author } from '../authors/author.entity';
|
||||
import appConfigMock from '../config/mock/app.config.mock';
|
||||
import authConfigMock from '../config/mock/auth.config.mock';
|
||||
import databaseConfigMock from '../config/mock/database.config.mock';
|
||||
import noteConfigMock from '../config/mock/note.config.mock';
|
||||
import {
|
||||
AlreadyInDBError,
|
||||
ForbiddenIdError,
|
||||
NotInDBError,
|
||||
PrimaryAliasDeletionForbiddenError,
|
||||
} from '../errors/errors';
|
||||
import { eventModuleConfig } from '../events';
|
||||
import { Group } from '../groups/group.entity';
|
||||
import { GroupsModule } from '../groups/groups.module';
|
||||
import { Identity } from '../identity/identity.entity';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
|
||||
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
|
||||
import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module';
|
||||
import { Edit } from '../revisions/edit.entity';
|
||||
import { Revision } from '../revisions/revision.entity';
|
||||
import { RevisionsModule } from '../revisions/revisions.module';
|
||||
import { Session } from '../users/session.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { Alias } from './alias.entity';
|
||||
import { AliasService } from './alias.service';
|
||||
import { Note } from './note.entity';
|
||||
import { NotesModule } from './notes.module';
|
||||
import { NotesService } from './notes.service';
|
||||
import { Tag } from './tag.entity';
|
||||
|
||||
describe('AliasService', () => {
|
||||
let service: AliasService;
|
||||
let noteRepo: Repository<Note>;
|
||||
let aliasRepo: Repository<Alias>;
|
||||
let forbiddenNoteId: string;
|
||||
beforeEach(async () => {
|
||||
noteRepo = new Repository<Note>(
|
||||
'',
|
||||
new EntityManager(
|
||||
new DataSource({
|
||||
type: 'sqlite',
|
||||
database: ':memory:',
|
||||
}),
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AliasService,
|
||||
NotesService,
|
||||
{
|
||||
provide: getRepositoryToken(Note),
|
||||
useValue: noteRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Alias),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Tag),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useClass: Repository,
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [
|
||||
appConfigMock,
|
||||
databaseConfigMock,
|
||||
authConfigMock,
|
||||
noteConfigMock,
|
||||
],
|
||||
}),
|
||||
LoggerModule,
|
||||
UsersModule,
|
||||
GroupsModule,
|
||||
RevisionsModule,
|
||||
NotesModule,
|
||||
RealtimeNoteModule,
|
||||
EventEmitterModule.forRoot(eventModuleConfig),
|
||||
],
|
||||
})
|
||||
.overrideProvider(getRepositoryToken(Note))
|
||||
.useValue(noteRepo)
|
||||
.overrideProvider(getRepositoryToken(Tag))
|
||||
.useClass(Repository)
|
||||
.overrideProvider(getRepositoryToken(Alias))
|
||||
.useClass(Repository)
|
||||
.overrideProvider(getRepositoryToken(User))
|
||||
.useClass(Repository)
|
||||
.overrideProvider(getRepositoryToken(AuthToken))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Identity))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Edit))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Revision))
|
||||
.useClass(Repository)
|
||||
.overrideProvider(getRepositoryToken(NoteGroupPermission))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(NoteUserPermission))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Group))
|
||||
.useClass(Repository)
|
||||
.overrideProvider(getRepositoryToken(Session))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Author))
|
||||
.useValue({})
|
||||
.compile();
|
||||
|
||||
const config = module.get<ConfigService>(ConfigService);
|
||||
forbiddenNoteId = config.get('noteConfig').forbiddenNoteIds[0];
|
||||
service = module.get<AliasService>(AliasService);
|
||||
noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note));
|
||||
aliasRepo = module.get<Repository<Alias>>(getRepositoryToken(Alias));
|
||||
});
|
||||
describe('addAlias', () => {
|
||||
const alias = 'testAlias';
|
||||
const alias2 = 'testAlias2';
|
||||
const user = User.create('hardcoded', 'Testy') as User;
|
||||
describe('creates', () => {
|
||||
it('an primary alias if no alias is already present', async () => {
|
||||
const note = Note.create(user) as Note;
|
||||
jest
|
||||
.spyOn(noteRepo, 'save')
|
||||
.mockImplementationOnce(async (note: Note): Promise<Note> => note);
|
||||
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(null);
|
||||
jest.spyOn(aliasRepo, 'findOne').mockResolvedValueOnce(null);
|
||||
const savedAlias = await service.addAlias(note, alias);
|
||||
expect(savedAlias.name).toEqual(alias);
|
||||
expect(savedAlias.primary).toBeTruthy();
|
||||
});
|
||||
it('an non-primary alias if an primary alias is already present', async () => {
|
||||
const note = Note.create(user, alias) as Note;
|
||||
jest
|
||||
.spyOn(noteRepo, 'save')
|
||||
.mockImplementationOnce(async (note: Note): Promise<Note> => note);
|
||||
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(null);
|
||||
jest.spyOn(aliasRepo, 'findOne').mockResolvedValueOnce(null);
|
||||
const savedAlias = await service.addAlias(note, alias2);
|
||||
expect(savedAlias.name).toEqual(alias2);
|
||||
expect(savedAlias.primary).toBeFalsy();
|
||||
});
|
||||
});
|
||||
describe('does not create an alias', () => {
|
||||
const note = Note.create(user, alias2) as Note;
|
||||
it('with an already used name', async () => {
|
||||
jest
|
||||
.spyOn(aliasRepo, 'findOne')
|
||||
.mockResolvedValueOnce(Alias.create(alias2, note, false) as Alias);
|
||||
await expect(service.addAlias(note, alias2)).rejects.toThrow(
|
||||
AlreadyInDBError,
|
||||
);
|
||||
});
|
||||
it('with a forbidden name', async () => {
|
||||
await expect(service.addAlias(note, forbiddenNoteId)).rejects.toThrow(
|
||||
ForbiddenIdError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAlias', () => {
|
||||
const alias = 'testAlias';
|
||||
const alias2 = 'testAlias2';
|
||||
const user = User.create('hardcoded', 'Testy') as User;
|
||||
describe('removes one alias correctly', () => {
|
||||
let note: Note;
|
||||
beforeAll(async () => {
|
||||
note = Note.create(user, alias) as Note;
|
||||
(await note.aliases).push(Alias.create(alias2, note, false) as Alias);
|
||||
});
|
||||
it('with two aliases', async () => {
|
||||
jest
|
||||
.spyOn(noteRepo, 'save')
|
||||
.mockImplementationOnce(async (note: Note): Promise<Note> => note);
|
||||
jest
|
||||
.spyOn(aliasRepo, 'remove')
|
||||
.mockImplementationOnce(
|
||||
async (alias: Alias): Promise<Alias> => alias,
|
||||
);
|
||||
const savedNote = await service.removeAlias(note, alias2);
|
||||
const aliases = await savedNote.aliases;
|
||||
expect(aliases).toHaveLength(1);
|
||||
expect(aliases[0].name).toEqual(alias);
|
||||
expect(aliases[0].primary).toBeTruthy();
|
||||
});
|
||||
it('with one alias, that is primary', async () => {
|
||||
jest
|
||||
.spyOn(noteRepo, 'save')
|
||||
.mockImplementationOnce(async (note: Note): Promise<Note> => note);
|
||||
jest
|
||||
.spyOn(aliasRepo, 'remove')
|
||||
.mockImplementationOnce(
|
||||
async (alias: Alias): Promise<Alias> => alias,
|
||||
);
|
||||
const savedNote = await service.removeAlias(note, alias);
|
||||
expect(await savedNote.aliases).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
describe('does not remove one alias', () => {
|
||||
let note: Note;
|
||||
beforeEach(async () => {
|
||||
note = Note.create(user, alias) as Note;
|
||||
(await note.aliases).push(Alias.create(alias2, note, false) as Alias);
|
||||
});
|
||||
it('if the alias is unknown', async () => {
|
||||
await expect(service.removeAlias(note, 'non existent')).rejects.toThrow(
|
||||
NotInDBError,
|
||||
);
|
||||
});
|
||||
it('if it is primary and not the last one', async () => {
|
||||
await expect(service.removeAlias(note, alias)).rejects.toThrow(
|
||||
PrimaryAliasDeletionForbiddenError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeAliasPrimary', () => {
|
||||
const user = User.create('hardcoded', 'Testy') as User;
|
||||
const aliasName = 'testAlias';
|
||||
let note: Note;
|
||||
let alias: Alias;
|
||||
let alias2: Alias;
|
||||
beforeEach(async () => {
|
||||
note = Note.create(user, aliasName) as Note;
|
||||
alias = Alias.create(aliasName, note, true) as Alias;
|
||||
alias2 = Alias.create('testAlias2', note, false) as Alias;
|
||||
(await note.aliases).push(
|
||||
Alias.create('testAlias2', note, false) as Alias,
|
||||
);
|
||||
});
|
||||
|
||||
it('mark the alias as primary', async () => {
|
||||
jest
|
||||
.spyOn(aliasRepo, 'findOneBy')
|
||||
.mockResolvedValueOnce(alias)
|
||||
.mockResolvedValueOnce(alias2);
|
||||
jest
|
||||
.spyOn(aliasRepo, 'save')
|
||||
.mockImplementationOnce(async (alias: Alias): Promise<Alias> => alias)
|
||||
.mockImplementationOnce(async (alias: Alias): Promise<Alias> => alias);
|
||||
const createQueryBuilder = {
|
||||
leftJoinAndSelect: () => createQueryBuilder,
|
||||
where: () => createQueryBuilder,
|
||||
orWhere: () => createQueryBuilder,
|
||||
setParameter: () => createQueryBuilder,
|
||||
getOne: async () => {
|
||||
return {
|
||||
...note,
|
||||
aliases: (await note.aliases).map((anAlias) => {
|
||||
if (anAlias.primary) {
|
||||
anAlias.primary = false;
|
||||
}
|
||||
if (anAlias.name === alias2.name) {
|
||||
anAlias.primary = true;
|
||||
}
|
||||
return anAlias;
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
jest
|
||||
.spyOn(noteRepo, 'createQueryBuilder')
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.mockImplementation(() => createQueryBuilder);
|
||||
const savedAlias = await service.makeAliasPrimary(note, alias2.name);
|
||||
expect(savedAlias.name).toEqual(alias2.name);
|
||||
expect(savedAlias.primary).toBeTruthy();
|
||||
});
|
||||
it('does not mark the alias as primary, if the alias does not exist', async () => {
|
||||
await expect(
|
||||
service.makeAliasPrimary(note, 'i_dont_exist'),
|
||||
).rejects.toThrow(NotInDBError);
|
||||
});
|
||||
});
|
||||
|
||||
it('toAliasDto correctly creates an AliasDto', () => {
|
||||
const aliasName = 'testAlias';
|
||||
const user = User.create('hardcoded', 'Testy') as User;
|
||||
const note = Note.create(user, aliasName) as Note;
|
||||
const alias = Alias.create(aliasName, note, true) as Alias;
|
||||
const aliasDto = service.toAliasDto(alias, note);
|
||||
expect(aliasDto.name).toEqual(aliasName);
|
||||
expect(aliasDto.primaryAlias).toBeTruthy();
|
||||
expect(aliasDto.noteId).toEqual(note.publicId);
|
||||
});
|
||||
});
|
196
backend/src/notes/alias.service.ts
Normal file
196
backend/src/notes/alias.service.ts
Normal file
|
@ -0,0 +1,196 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AlreadyInDBError,
|
||||
NotInDBError,
|
||||
PrimaryAliasDeletionForbiddenError,
|
||||
} from '../errors/errors';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { AliasDto } from './alias.dto';
|
||||
import { Alias } from './alias.entity';
|
||||
import { Note } from './note.entity';
|
||||
import { NotesService } from './notes.service';
|
||||
import { getPrimaryAlias } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class AliasService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@InjectRepository(Note) private noteRepository: Repository<Note>,
|
||||
@InjectRepository(Alias) private aliasRepository: Repository<Alias>,
|
||||
@Inject(forwardRef(() => NotesService)) private notesService: NotesService,
|
||||
) {
|
||||
this.logger.setContext(AliasService.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Add the specified alias to the note.
|
||||
* @param {Note} note - the note to add the alias to
|
||||
* @param {string} alias - the alias to add to the note
|
||||
* @throws {AlreadyInDBError} the alias is already in use.
|
||||
* @throws {ForbiddenIdError} the requested id or alias is forbidden
|
||||
* @return {Alias} the new alias
|
||||
*/
|
||||
async addAlias(note: Note, alias: string): Promise<Alias> {
|
||||
this.notesService.checkNoteIdOrAlias(alias);
|
||||
|
||||
const foundAlias = await this.aliasRepository.findOne({
|
||||
where: { name: alias },
|
||||
});
|
||||
if (foundAlias !== null) {
|
||||
this.logger.debug(`The alias '${alias}' is already used.`, 'addAlias');
|
||||
throw new AlreadyInDBError(`The alias '${alias}' is already used.`);
|
||||
}
|
||||
|
||||
const foundNote = await this.noteRepository.findOne({
|
||||
where: { publicId: alias },
|
||||
});
|
||||
if (foundNote !== null) {
|
||||
this.logger.debug(
|
||||
`The alias '${alias}' is already a public id.`,
|
||||
'addAlias',
|
||||
);
|
||||
throw new AlreadyInDBError(
|
||||
`The alias '${alias}' is already a public id.`,
|
||||
);
|
||||
}
|
||||
let newAlias;
|
||||
if ((await note.aliases).length === 0) {
|
||||
// the first alias is automatically made the primary alias
|
||||
newAlias = Alias.create(alias, note, true);
|
||||
} else {
|
||||
newAlias = Alias.create(alias, note, false);
|
||||
}
|
||||
(await note.aliases).push(newAlias as Alias);
|
||||
|
||||
await this.noteRepository.save(note);
|
||||
return newAlias as Alias;
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Set the specified alias as the primary alias of the note.
|
||||
* @param {Note} note - the note to change the primary alias
|
||||
* @param {string} alias - the alias to be the new primary alias of the note
|
||||
* @throws {ForbiddenIdError} the requested id or alias is forbidden
|
||||
* @throws {NotInDBError} the alias is not part of this note.
|
||||
* @return {Alias} the new primary alias
|
||||
*/
|
||||
async makeAliasPrimary(note: Note, alias: string): Promise<Alias> {
|
||||
let newPrimaryFound = false;
|
||||
let oldPrimaryId = 0;
|
||||
let newPrimaryId = 0;
|
||||
|
||||
this.notesService.checkNoteIdOrAlias(alias);
|
||||
|
||||
for (const anAlias of await note.aliases) {
|
||||
// found old primary
|
||||
if (anAlias.primary) {
|
||||
oldPrimaryId = anAlias.id;
|
||||
}
|
||||
|
||||
// found new primary
|
||||
if (anAlias.name === alias) {
|
||||
newPrimaryFound = true;
|
||||
newPrimaryId = anAlias.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!newPrimaryFound) {
|
||||
// the provided alias is not already an alias of this note
|
||||
this.logger.debug(
|
||||
`The alias '${alias}' is not used by this note.`,
|
||||
'makeAliasPrimary',
|
||||
);
|
||||
throw new NotInDBError(`The alias '${alias}' is not used by this note.`);
|
||||
}
|
||||
|
||||
const oldPrimary = await this.aliasRepository.findOneBy({
|
||||
id: oldPrimaryId,
|
||||
});
|
||||
const newPrimary = await this.aliasRepository.findOneBy({
|
||||
id: newPrimaryId,
|
||||
});
|
||||
|
||||
if (!oldPrimary || !newPrimary) {
|
||||
throw new Error('This should not happen!');
|
||||
}
|
||||
|
||||
oldPrimary.primary = false;
|
||||
newPrimary.primary = true;
|
||||
|
||||
await this.aliasRepository.save(oldPrimary);
|
||||
await this.aliasRepository.save(newPrimary);
|
||||
|
||||
return newPrimary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Remove the specified alias from the note.
|
||||
* @param {Note} note - the note to remove the alias from
|
||||
* @param {string} alias - the alias to remove from the note
|
||||
* @throws {ForbiddenIdError} the requested id or alias is forbidden
|
||||
* @throws {NotInDBError} the alias is not part of this note.
|
||||
* @throws {PrimaryAliasDeletionForbiddenError} the primary alias can only be deleted if it's the only alias
|
||||
*/
|
||||
async removeAlias(note: Note, alias: string): Promise<Note> {
|
||||
this.notesService.checkNoteIdOrAlias(alias);
|
||||
const primaryAlias = await getPrimaryAlias(note);
|
||||
|
||||
if (primaryAlias === alias && (await note.aliases).length !== 1) {
|
||||
this.logger.debug(
|
||||
`The alias '${alias}' is the primary alias, which can only be removed if it's the only alias.`,
|
||||
'removeAlias',
|
||||
);
|
||||
throw new PrimaryAliasDeletionForbiddenError(
|
||||
`The alias '${alias}' is the primary alias, which can only be removed if it's the only alias.`,
|
||||
);
|
||||
}
|
||||
|
||||
const filteredAliases = (await note.aliases).filter(
|
||||
(anAlias) => anAlias.name !== alias,
|
||||
);
|
||||
if ((await note.aliases).length === filteredAliases.length) {
|
||||
this.logger.debug(
|
||||
`The alias '${alias}' is not used by this note or is the primary alias, which can't be removed.`,
|
||||
'removeAlias',
|
||||
);
|
||||
throw new NotInDBError(
|
||||
`The alias '${alias}' is not used by this note or is the primary alias, which can't be removed.`,
|
||||
);
|
||||
}
|
||||
const aliasToDelete = (await note.aliases).find(
|
||||
(anAlias) => anAlias.name === alias,
|
||||
);
|
||||
if (aliasToDelete !== undefined) {
|
||||
await this.aliasRepository.remove(aliasToDelete);
|
||||
}
|
||||
note.aliases = Promise.resolve(filteredAliases);
|
||||
return await this.noteRepository.save(note);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Build AliasDto from a note.
|
||||
* @param {Alias} alias - the alias to use
|
||||
* @param {Note} note - the note to use
|
||||
* @return {AliasDto} the built AliasDto
|
||||
* @throws {NotInDBError} the specified alias does not exist
|
||||
*/
|
||||
toAliasDto(alias: Alias, note: Note): AliasDto {
|
||||
return {
|
||||
name: alias.name,
|
||||
primaryAlias: alias.primary,
|
||||
noteId: note.publicId,
|
||||
};
|
||||
}
|
||||
}
|
156
backend/src/notes/note-metadata.dto.ts
Normal file
156
backend/src/notes/note-metadata.dto.ts
Normal file
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDate,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
import { AliasDto } from './alias.dto';
|
||||
import { NotePermissionsDto } from './note-permissions.dto';
|
||||
|
||||
export class NoteMetadataDto extends BaseDto {
|
||||
/**
|
||||
* ID of the note
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* All aliases of the note (including the primaryAlias)
|
||||
*/
|
||||
@IsArray()
|
||||
@ValidateNested()
|
||||
@ApiProperty()
|
||||
aliases: AliasDto[];
|
||||
|
||||
/**
|
||||
* The primary adress of the note
|
||||
* If at least one alias is set, this is the primary alias
|
||||
* If no alias is set, this is the note's ID
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
primaryAddress: string;
|
||||
|
||||
/**
|
||||
* Title of the note
|
||||
* Does not contain any markup but might be empty
|
||||
* @example "Shopping List"
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Description of the note
|
||||
* Does not contain any markup but might be empty
|
||||
* @example Everything I want to buy
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* List of tags assigned to this note
|
||||
* @example "['shopping', 'personal']
|
||||
*/
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ApiProperty()
|
||||
tags: string[];
|
||||
|
||||
@IsNumber()
|
||||
@ApiProperty()
|
||||
version: number;
|
||||
|
||||
/**
|
||||
* Datestring of the last time this note was updated
|
||||
* @example "2020-12-01 12:23:34"
|
||||
*/
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
@ApiProperty()
|
||||
updatedAt: Date;
|
||||
|
||||
/**
|
||||
* User that last edited the note
|
||||
*/
|
||||
@IsString()
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
updateUsername: string | null;
|
||||
|
||||
/**
|
||||
* Counts how many times the published note has been viewed
|
||||
* @example 42
|
||||
*/
|
||||
@IsNumber()
|
||||
@ApiProperty()
|
||||
viewCount: number;
|
||||
|
||||
/**
|
||||
* Datestring of the time this note was created
|
||||
* @example "2020-12-01 12:23:34"
|
||||
*/
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* List of usernames that edited the note
|
||||
* @example "['john.smith', 'jane.smith']"
|
||||
*/
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ApiProperty()
|
||||
editedBy: string[];
|
||||
|
||||
/**
|
||||
* Permissions currently in effect for the note
|
||||
*/
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => NotePermissionsDto)
|
||||
@ApiProperty({ type: NotePermissionsDto })
|
||||
permissions: NotePermissionsDto;
|
||||
}
|
||||
|
||||
export class NoteMetadataUpdateDto {
|
||||
/**
|
||||
* New title of the note
|
||||
* Can not contain any markup and might be empty
|
||||
* @example "Shopping List"
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* New description of the note
|
||||
* Can not contain any markup but might be empty
|
||||
* @example Everything I want to buy
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* New list of tags assigned to this note
|
||||
* @example "['shopping', 'personal']
|
||||
*/
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ApiProperty()
|
||||
tags: string[];
|
||||
}
|
134
backend/src/notes/note-permissions.dto.ts
Normal file
134
backend/src/notes/note-permissions.dto.ts
Normal file
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
|
||||
export class NoteUserPermissionEntryDto extends BaseDto {
|
||||
/**
|
||||
* Username of the User this permission applies to
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
username: string;
|
||||
|
||||
/**
|
||||
* True if the user is allowed to edit the note
|
||||
* @example false
|
||||
*/
|
||||
@IsBoolean()
|
||||
@ApiProperty()
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export class NoteUserPermissionUpdateDto {
|
||||
/**
|
||||
* Username of the user this permission should apply to
|
||||
* @example "john.smith"
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
username: string;
|
||||
|
||||
/**
|
||||
* True if the user should be allowed to edit the note
|
||||
* @example false
|
||||
*/
|
||||
@IsBoolean()
|
||||
@ApiProperty()
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export class NoteGroupPermissionEntryDto {
|
||||
/**
|
||||
* Name of the Group this permission applies to
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
groupName: string;
|
||||
|
||||
/**
|
||||
* True if the group members are allowed to edit the note
|
||||
* @example false
|
||||
*/
|
||||
@IsBoolean()
|
||||
@ApiProperty()
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export class NoteGroupPermissionUpdateDto {
|
||||
/**
|
||||
* Name of the group this permission should apply to
|
||||
* @example "superheroes"
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
groupName: string;
|
||||
|
||||
/**
|
||||
* True if the group members should be allowed to edit the note
|
||||
* @example false
|
||||
*/
|
||||
@IsBoolean()
|
||||
@ApiProperty()
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export class NotePermissionsDto {
|
||||
/**
|
||||
* Username of the User this permission applies to
|
||||
*/
|
||||
@IsString()
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
owner: string | null;
|
||||
|
||||
/**
|
||||
* List of users the note is shared with
|
||||
*/
|
||||
@ValidateNested({ each: true })
|
||||
@IsArray()
|
||||
@Type(() => NoteUserPermissionEntryDto)
|
||||
@ApiProperty({ isArray: true, type: NoteUserPermissionEntryDto })
|
||||
sharedToUsers: NoteUserPermissionEntryDto[];
|
||||
|
||||
/**
|
||||
* List of groups the note is shared with
|
||||
*/
|
||||
@ValidateNested({ each: true })
|
||||
@IsArray()
|
||||
@Type(() => NoteGroupPermissionEntryDto)
|
||||
@ApiProperty({ isArray: true, type: NoteGroupPermissionEntryDto })
|
||||
sharedToGroups: NoteGroupPermissionEntryDto[];
|
||||
}
|
||||
|
||||
export class NotePermissionsUpdateDto {
|
||||
/**
|
||||
* List of users the note should be shared with
|
||||
*/
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => NoteUserPermissionUpdateDto)
|
||||
@ApiProperty({ isArray: true, type: NoteUserPermissionUpdateDto })
|
||||
sharedToUsers: NoteUserPermissionUpdateDto[];
|
||||
|
||||
/**
|
||||
* List of groups the note should be shared with
|
||||
*/
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => NoteGroupPermissionUpdateDto)
|
||||
@ApiProperty({ isArray: true, type: NoteGroupPermissionUpdateDto })
|
||||
sharedToGroups: NoteGroupPermissionUpdateDto[];
|
||||
}
|
38
backend/src/notes/note.dto.ts
Normal file
38
backend/src/notes/note.dto.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsString, ValidateNested } from 'class-validator';
|
||||
|
||||
import { EditDto } from '../revisions/edit.dto';
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
import { NoteMetadataDto } from './note-metadata.dto';
|
||||
|
||||
export class NoteDto extends BaseDto {
|
||||
/**
|
||||
* Markdown content of the note
|
||||
* @example "# I am a heading"
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* Metadata of the note
|
||||
*/
|
||||
@ValidateNested()
|
||||
@ApiProperty({ type: NoteMetadataDto })
|
||||
metadata: NoteMetadataDto;
|
||||
|
||||
/**
|
||||
* Edit information of this note
|
||||
*/
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EditDto)
|
||||
@ApiProperty({ isArray: true, type: EditDto })
|
||||
editedByAtPosition: EditDto[];
|
||||
}
|
131
backend/src/notes/note.entity.ts
Normal file
131
backend/src/notes/note.entity.ts
Normal file
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { HistoryEntry } from '../history/history-entry.entity';
|
||||
import { MediaUpload } from '../media/media-upload.entity';
|
||||
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
|
||||
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
|
||||
import { Revision } from '../revisions/revision.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
import { Alias } from './alias.entity';
|
||||
import { Tag } from './tag.entity';
|
||||
import { generatePublicId } from './utils';
|
||||
|
||||
@Entity()
|
||||
export class Note {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
publicId: string;
|
||||
|
||||
@OneToMany(
|
||||
(_) => Alias,
|
||||
(alias) => alias.note,
|
||||
{ cascade: true }, // This ensures that embedded Aliases are automatically saved to the database
|
||||
)
|
||||
aliases: Promise<Alias[]>;
|
||||
|
||||
@OneToMany(
|
||||
(_) => NoteGroupPermission,
|
||||
(groupPermission) => groupPermission.note,
|
||||
{ cascade: true }, // This ensures that embedded NoteGroupPermissions are automatically saved to the database
|
||||
)
|
||||
groupPermissions: Promise<NoteGroupPermission[]>;
|
||||
|
||||
@OneToMany(
|
||||
(_) => NoteUserPermission,
|
||||
(userPermission) => userPermission.note,
|
||||
{ cascade: true }, // This ensures that embedded NoteUserPermission are automatically saved to the database
|
||||
)
|
||||
userPermissions: Promise<NoteUserPermission[]>;
|
||||
|
||||
@Column({
|
||||
nullable: false,
|
||||
default: 0,
|
||||
})
|
||||
viewCount: number;
|
||||
|
||||
@ManyToOne((_) => User, (user) => user.ownedNotes, {
|
||||
onDelete: 'CASCADE', // This deletes the Note, when the associated User is deleted
|
||||
nullable: true,
|
||||
})
|
||||
owner: Promise<User | null>;
|
||||
|
||||
@OneToMany((_) => Revision, (revision) => revision.note, { cascade: true })
|
||||
revisions: Promise<Revision[]>;
|
||||
|
||||
@OneToMany((_) => HistoryEntry, (historyEntry) => historyEntry.user)
|
||||
historyEntries: Promise<HistoryEntry[]>;
|
||||
|
||||
@OneToMany((_) => MediaUpload, (mediaUpload) => mediaUpload.note)
|
||||
mediaUploads: Promise<MediaUpload[]>;
|
||||
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: 'text',
|
||||
})
|
||||
description: string | null;
|
||||
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: 'text',
|
||||
})
|
||||
title: string | null;
|
||||
|
||||
@ManyToMany((_) => Tag, (tag) => tag.notes, { eager: true, cascade: true })
|
||||
@JoinTable()
|
||||
tags: Promise<Tag[]>;
|
||||
|
||||
@Column({
|
||||
default: 2,
|
||||
})
|
||||
version: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Creates a new Note
|
||||
* @param owner The owner of the note
|
||||
* @param alias Optional primary alias
|
||||
*/
|
||||
public static create(
|
||||
owner: User | null,
|
||||
alias?: string,
|
||||
): Omit<Note, 'id' | 'createdAt'> {
|
||||
const newNote = new Note();
|
||||
newNote.publicId = generatePublicId();
|
||||
newNote.aliases = alias
|
||||
? Promise.resolve([Alias.create(alias, newNote, true) as Alias])
|
||||
: Promise.resolve([]);
|
||||
newNote.userPermissions = Promise.resolve([]);
|
||||
newNote.groupPermissions = Promise.resolve([]);
|
||||
newNote.viewCount = 0;
|
||||
newNote.owner = Promise.resolve(owner);
|
||||
newNote.revisions = Promise.resolve([]);
|
||||
newNote.historyEntries = Promise.resolve([]);
|
||||
newNote.mediaUploads = Promise.resolve([]);
|
||||
newNote.description = null;
|
||||
newNote.title = null;
|
||||
newNote.tags = Promise.resolve([]);
|
||||
newNote.version = 2;
|
||||
return newNote;
|
||||
}
|
||||
}
|
20
backend/src/notes/note.media-deletion.dto.ts
Normal file
20
backend/src/notes/note.media-deletion.dto.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
|
||||
export class NoteMediaDeletionDto extends BaseDto {
|
||||
/**
|
||||
* Should the associated mediaUploads be keept
|
||||
* @default false
|
||||
* @example false
|
||||
*/
|
||||
@IsBoolean()
|
||||
@ApiProperty()
|
||||
keepMedia: boolean;
|
||||
}
|
45
backend/src/notes/notes.module.ts
Normal file
45
backend/src/notes/notes.module.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { GroupsModule } from '../groups/groups.module';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
|
||||
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
|
||||
import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module';
|
||||
import { RevisionsModule } from '../revisions/revisions.module';
|
||||
import { User } from '../users/user.entity';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { Alias } from './alias.entity';
|
||||
import { AliasService } from './alias.service';
|
||||
import { Note } from './note.entity';
|
||||
import { NotesService } from './notes.service';
|
||||
import { Tag } from './tag.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Note,
|
||||
Tag,
|
||||
NoteGroupPermission,
|
||||
NoteUserPermission,
|
||||
User,
|
||||
Alias,
|
||||
]),
|
||||
RevisionsModule,
|
||||
UsersModule,
|
||||
GroupsModule,
|
||||
LoggerModule,
|
||||
ConfigModule,
|
||||
RealtimeNoteModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [NotesService, AliasService],
|
||||
exports: [NotesService, AliasService],
|
||||
})
|
||||
export class NotesModule {}
|
768
backend/src/notes/notes.service.spec.ts
Normal file
768
backend/src/notes/notes.service.spec.ts
Normal file
|
@ -0,0 +1,768 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
FindOptionsWhere,
|
||||
Repository,
|
||||
} from 'typeorm';
|
||||
|
||||
import { AuthToken } from '../auth/auth-token.entity';
|
||||
import { Author } from '../authors/author.entity';
|
||||
import { DefaultAccessPermission } from '../config/default-access-permission.enum';
|
||||
import appConfigMock from '../config/mock/app.config.mock';
|
||||
import authConfigMock from '../config/mock/auth.config.mock';
|
||||
import databaseConfigMock from '../config/mock/database.config.mock';
|
||||
import {
|
||||
createDefaultMockNoteConfig,
|
||||
registerNoteConfig,
|
||||
} from '../config/mock/note.config.mock';
|
||||
import { NoteConfig } from '../config/note.config';
|
||||
import {
|
||||
AlreadyInDBError,
|
||||
ForbiddenIdError,
|
||||
MaximumDocumentLengthExceededError,
|
||||
NotInDBError,
|
||||
} from '../errors/errors';
|
||||
import { eventModuleConfig, NoteEvent } from '../events';
|
||||
import { Group } from '../groups/group.entity';
|
||||
import { GroupsModule } from '../groups/groups.module';
|
||||
import { SpecialGroup } from '../groups/groups.special';
|
||||
import { Identity } from '../identity/identity.entity';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
|
||||
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
|
||||
import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module';
|
||||
import { Edit } from '../revisions/edit.entity';
|
||||
import { Revision } from '../revisions/revision.entity';
|
||||
import { RevisionsModule } from '../revisions/revisions.module';
|
||||
import { Session } from '../users/session.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { Alias } from './alias.entity';
|
||||
import { AliasService } from './alias.service';
|
||||
import { Note } from './note.entity';
|
||||
import { NotesService } from './notes.service';
|
||||
import { Tag } from './tag.entity';
|
||||
|
||||
describe('NotesService', () => {
|
||||
let service: NotesService;
|
||||
const noteMockConfig: NoteConfig = createDefaultMockNoteConfig();
|
||||
let noteRepo: Repository<Note>;
|
||||
let revisionRepo: Repository<Revision>;
|
||||
let userRepo: Repository<User>;
|
||||
let groupRepo: Repository<Group>;
|
||||
let forbiddenNoteId: string;
|
||||
let everyoneDefaultAccessPermission: string;
|
||||
let loggedinDefaultAccessPermission: string;
|
||||
let eventEmitter: EventEmitter2;
|
||||
const everyone = Group.create(
|
||||
SpecialGroup.EVERYONE,
|
||||
SpecialGroup.EVERYONE,
|
||||
true,
|
||||
);
|
||||
const loggedin = Group.create(
|
||||
SpecialGroup.LOGGED_IN,
|
||||
SpecialGroup.LOGGED_IN,
|
||||
true,
|
||||
);
|
||||
|
||||
/**
|
||||
* Creates a Note and a corresponding User and Group for testing.
|
||||
* The Note does not have any aliases.
|
||||
*/
|
||||
async function getMockData(): Promise<[Note, User, Group]> {
|
||||
const user = User.create('hardcoded', 'Testy') as User;
|
||||
const author = Author.create(1);
|
||||
author.user = Promise.resolve(user);
|
||||
const group = Group.create('testGroup', 'testGroup', false) as Group;
|
||||
const content = 'testContent';
|
||||
jest
|
||||
.spyOn(noteRepo, 'save')
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.mockImplementation(async (note: Note): Promise<Note> => note);
|
||||
mockGroupRepo();
|
||||
const note = await service.createNote(content, null);
|
||||
const revisions = await note.revisions;
|
||||
revisions[0].edits = Promise.resolve([
|
||||
{
|
||||
revisions: Promise.resolve(revisions),
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
updatedAt: new Date(1549312452000),
|
||||
author: Promise.resolve(author),
|
||||
} as Edit,
|
||||
{
|
||||
revisions: Promise.resolve(revisions),
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
updatedAt: new Date(1549312452001),
|
||||
author: Promise.resolve(author),
|
||||
} as Edit,
|
||||
]);
|
||||
revisions[0].createdAt = new Date(1549312452000);
|
||||
jest.spyOn(revisionRepo, 'findOne').mockResolvedValue(revisions[0]);
|
||||
const createQueryBuilder = {
|
||||
innerJoin: () => createQueryBuilder,
|
||||
where: () => createQueryBuilder,
|
||||
getMany: () => [user],
|
||||
};
|
||||
jest
|
||||
.spyOn(userRepo, 'createQueryBuilder')
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.mockImplementation(() => createQueryBuilder);
|
||||
note.publicId = 'testId';
|
||||
note.title = 'testTitle';
|
||||
note.description = 'testDescription';
|
||||
note.owner = Promise.resolve(user);
|
||||
note.userPermissions = Promise.resolve([
|
||||
{
|
||||
id: 1,
|
||||
note: Promise.resolve(note),
|
||||
user: Promise.resolve(user),
|
||||
canEdit: true,
|
||||
},
|
||||
]);
|
||||
note.groupPermissions = Promise.resolve([
|
||||
{
|
||||
id: 1,
|
||||
note: Promise.resolve(note),
|
||||
group: Promise.resolve(group),
|
||||
canEdit: true,
|
||||
},
|
||||
]);
|
||||
note.tags = Promise.resolve([
|
||||
{
|
||||
id: 1,
|
||||
name: 'testTag',
|
||||
notes: Promise.resolve([note]),
|
||||
},
|
||||
]);
|
||||
note.viewCount = 1337;
|
||||
|
||||
return [note, user, group];
|
||||
}
|
||||
|
||||
function mockGroupRepo() {
|
||||
jest.spyOn(groupRepo, 'findOne').mockReset();
|
||||
jest.spyOn(groupRepo, 'findOne').mockImplementation((args) => {
|
||||
const groupName = (args.where as FindOptionsWhere<Group>).name;
|
||||
if (groupName === loggedin.name) {
|
||||
return Promise.resolve(loggedin as Group);
|
||||
} else if (groupName === everyone.name) {
|
||||
return Promise.resolve(everyone as Group);
|
||||
} else {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
/**
|
||||
* We need to have *one* userRepo for both the providers array and
|
||||
* the overrideProvider call, as otherwise we have two instances
|
||||
* and the mock of createQueryBuilder replaces the wrong one
|
||||
* **/
|
||||
userRepo = new Repository<User>(
|
||||
'',
|
||||
new EntityManager(
|
||||
new DataSource({
|
||||
type: 'sqlite',
|
||||
database: ':memory:',
|
||||
}),
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
noteRepo = new Repository<Note>(
|
||||
'',
|
||||
new EntityManager(
|
||||
new DataSource({
|
||||
type: 'sqlite',
|
||||
database: ':memory:',
|
||||
}),
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
groupRepo = new Repository<Group>(
|
||||
'',
|
||||
new EntityManager(
|
||||
new DataSource({
|
||||
type: 'sqlite',
|
||||
database: ':memory:',
|
||||
}),
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
NotesService,
|
||||
AliasService,
|
||||
{
|
||||
provide: getRepositoryToken(Note),
|
||||
useValue: noteRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Tag),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Alias),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useValue: userRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Group),
|
||||
useValue: groupRepo,
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
LoggerModule,
|
||||
UsersModule,
|
||||
GroupsModule,
|
||||
RevisionsModule,
|
||||
RealtimeNoteModule,
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [
|
||||
appConfigMock,
|
||||
databaseConfigMock,
|
||||
authConfigMock,
|
||||
registerNoteConfig(noteMockConfig),
|
||||
],
|
||||
}),
|
||||
EventEmitterModule.forRoot(eventModuleConfig),
|
||||
],
|
||||
})
|
||||
.overrideProvider(getRepositoryToken(Note))
|
||||
.useValue(noteRepo)
|
||||
.overrideProvider(getRepositoryToken(Tag))
|
||||
.useClass(Repository)
|
||||
.overrideProvider(getRepositoryToken(Alias))
|
||||
.useClass(Repository)
|
||||
.overrideProvider(getRepositoryToken(User))
|
||||
.useValue(userRepo)
|
||||
.overrideProvider(getRepositoryToken(AuthToken))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Identity))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Edit))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Revision))
|
||||
.useClass(Repository)
|
||||
.overrideProvider(getRepositoryToken(NoteGroupPermission))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(NoteUserPermission))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Group))
|
||||
.useValue(groupRepo)
|
||||
.overrideProvider(getRepositoryToken(Session))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Author))
|
||||
.useValue({})
|
||||
.compile();
|
||||
|
||||
const config = module.get<ConfigService>(ConfigService);
|
||||
const noteConfig = config.get<NoteConfig>('noteConfig') as NoteConfig;
|
||||
forbiddenNoteId = noteConfig.forbiddenNoteIds[0];
|
||||
everyoneDefaultAccessPermission = noteConfig.permissions.default.everyone;
|
||||
loggedinDefaultAccessPermission = noteConfig.permissions.default.loggedIn;
|
||||
service = module.get<NotesService>(NotesService);
|
||||
noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note));
|
||||
revisionRepo = module.get<Repository<Revision>>(
|
||||
getRepositoryToken(Revision),
|
||||
);
|
||||
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getUserNotes', () => {
|
||||
describe('works', () => {
|
||||
const user = User.create('hardcoded', 'Testy') as User;
|
||||
const alias = 'alias';
|
||||
const note = Note.create(user, alias) as Note;
|
||||
|
||||
it('with no note', async () => {
|
||||
const createQueryBuilder = {
|
||||
leftJoinAndSelect: () => createQueryBuilder,
|
||||
where: () => createQueryBuilder,
|
||||
getMany: async () => {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
jest
|
||||
.spyOn(noteRepo, 'createQueryBuilder')
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.mockImplementation(() => createQueryBuilder);
|
||||
const notes = await service.getUserNotes(user);
|
||||
expect(notes).toEqual([]);
|
||||
});
|
||||
|
||||
it('with one note', async () => {
|
||||
const createQueryBuilder = {
|
||||
leftJoinAndSelect: () => createQueryBuilder,
|
||||
where: () => createQueryBuilder,
|
||||
getMany: async () => {
|
||||
return [note];
|
||||
},
|
||||
};
|
||||
jest
|
||||
.spyOn(noteRepo, 'createQueryBuilder')
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.mockImplementation(() => createQueryBuilder);
|
||||
const notes = await service.getUserNotes(user);
|
||||
expect(notes).toEqual([note]);
|
||||
});
|
||||
|
||||
it('with multiple note', async () => {
|
||||
const createQueryBuilder = {
|
||||
leftJoinAndSelect: () => createQueryBuilder,
|
||||
where: () => createQueryBuilder,
|
||||
getMany: async () => {
|
||||
return [note, note];
|
||||
},
|
||||
};
|
||||
jest
|
||||
.spyOn(noteRepo, 'createQueryBuilder')
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.mockImplementation(() => createQueryBuilder);
|
||||
const notes = await service.getUserNotes(user);
|
||||
expect(notes).toEqual([note, note]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNote', () => {
|
||||
const user = User.create('hardcoded', 'Testy') as User;
|
||||
const alias = 'alias';
|
||||
const content = 'testContent';
|
||||
describe('works', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(noteRepo, 'save')
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.mockImplementation(async (note: Note): Promise<Note> => note);
|
||||
mockGroupRepo();
|
||||
});
|
||||
it('without alias, without owner', async () => {
|
||||
const newNote = await service.createNote(content, null);
|
||||
const revisions = await newNote.revisions;
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(revisions[0].content).toEqual(content);
|
||||
expect(await newNote.historyEntries).toHaveLength(0);
|
||||
expect(await newNote.userPermissions).toHaveLength(0);
|
||||
const groupPermissions = await newNote.groupPermissions;
|
||||
expect(groupPermissions).toHaveLength(2);
|
||||
expect(groupPermissions[0].canEdit).toEqual(
|
||||
everyoneDefaultAccessPermission !== DefaultAccessPermission.WRITE,
|
||||
);
|
||||
expect((await groupPermissions[0].group).name).toEqual(
|
||||
SpecialGroup.EVERYONE,
|
||||
);
|
||||
expect(groupPermissions[1].canEdit).toEqual(
|
||||
loggedinDefaultAccessPermission === DefaultAccessPermission.WRITE,
|
||||
);
|
||||
expect((await groupPermissions[1].group).name).toEqual(
|
||||
SpecialGroup.LOGGED_IN,
|
||||
);
|
||||
expect(await newNote.tags).toHaveLength(0);
|
||||
expect(await newNote.owner).toBeNull();
|
||||
expect(await newNote.aliases).toHaveLength(0);
|
||||
});
|
||||
it('without alias, with owner', async () => {
|
||||
const newNote = await service.createNote(content, user);
|
||||
const revisions = await newNote.revisions;
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(revisions[0].content).toEqual(content);
|
||||
expect(await newNote.historyEntries).toHaveLength(1);
|
||||
expect(await (await newNote.historyEntries)[0].user).toEqual(user);
|
||||
expect(await newNote.userPermissions).toHaveLength(0);
|
||||
const groupPermissions = await newNote.groupPermissions;
|
||||
expect(groupPermissions).toHaveLength(2);
|
||||
expect(groupPermissions[0].canEdit).toEqual(
|
||||
everyoneDefaultAccessPermission === DefaultAccessPermission.WRITE,
|
||||
);
|
||||
expect((await groupPermissions[0].group).name).toEqual(
|
||||
SpecialGroup.EVERYONE,
|
||||
);
|
||||
expect(groupPermissions[1].canEdit).toEqual(
|
||||
loggedinDefaultAccessPermission === DefaultAccessPermission.WRITE,
|
||||
);
|
||||
expect((await groupPermissions[1].group).name).toEqual(
|
||||
SpecialGroup.LOGGED_IN,
|
||||
);
|
||||
expect(await newNote.tags).toHaveLength(0);
|
||||
expect(await newNote.owner).toEqual(user);
|
||||
expect(await newNote.aliases).toHaveLength(0);
|
||||
});
|
||||
it('with alias, without owner', async () => {
|
||||
const newNote = await service.createNote(content, null, alias);
|
||||
const revisions = await newNote.revisions;
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(revisions[0].content).toEqual(content);
|
||||
expect(await newNote.historyEntries).toHaveLength(0);
|
||||
expect(await newNote.userPermissions).toHaveLength(0);
|
||||
const groupPermissions = await newNote.groupPermissions;
|
||||
expect(groupPermissions).toHaveLength(2);
|
||||
expect(groupPermissions[0].canEdit).toEqual(
|
||||
everyoneDefaultAccessPermission !== DefaultAccessPermission.WRITE,
|
||||
);
|
||||
expect((await groupPermissions[0].group).name).toEqual(
|
||||
SpecialGroup.EVERYONE,
|
||||
);
|
||||
expect(groupPermissions[1].canEdit).toEqual(
|
||||
loggedinDefaultAccessPermission === DefaultAccessPermission.WRITE,
|
||||
);
|
||||
expect((await groupPermissions[1].group).name).toEqual(
|
||||
SpecialGroup.LOGGED_IN,
|
||||
);
|
||||
expect(await newNote.tags).toHaveLength(0);
|
||||
expect(await newNote.owner).toBeNull();
|
||||
expect(await newNote.aliases).toHaveLength(1);
|
||||
});
|
||||
it('with alias, with owner', async () => {
|
||||
const newNote = await service.createNote(content, user, alias);
|
||||
const revisions = await newNote.revisions;
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(revisions[0].content).toEqual(content);
|
||||
expect(await newNote.historyEntries).toHaveLength(1);
|
||||
expect(await (await newNote.historyEntries)[0].user).toEqual(user);
|
||||
expect(await newNote.userPermissions).toHaveLength(0);
|
||||
const groupPermissions = await newNote.groupPermissions;
|
||||
expect(groupPermissions).toHaveLength(2);
|
||||
expect(groupPermissions[0].canEdit).toEqual(
|
||||
everyoneDefaultAccessPermission === DefaultAccessPermission.WRITE,
|
||||
);
|
||||
expect((await groupPermissions[0].group).name).toEqual(
|
||||
SpecialGroup.EVERYONE,
|
||||
);
|
||||
expect(groupPermissions[1].canEdit).toEqual(
|
||||
loggedinDefaultAccessPermission === DefaultAccessPermission.WRITE,
|
||||
);
|
||||
expect((await groupPermissions[1].group).name).toEqual(
|
||||
SpecialGroup.LOGGED_IN,
|
||||
);
|
||||
expect(await newNote.tags).toHaveLength(0);
|
||||
expect(await newNote.owner).toEqual(user);
|
||||
expect(await newNote.aliases).toHaveLength(1);
|
||||
expect((await newNote.aliases)[0].name).toEqual(alias);
|
||||
});
|
||||
describe('with maxDocumentLength 1000', () => {
|
||||
beforeEach(() => (noteMockConfig.maxDocumentLength = 1000));
|
||||
it('and content has length maxDocumentLength', async () => {
|
||||
const content = 'x'.repeat(noteMockConfig.maxDocumentLength);
|
||||
const newNote = await service.createNote(content, user, alias);
|
||||
const revisions = await newNote.revisions;
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(revisions[0].content).toEqual(content);
|
||||
expect(await newNote.historyEntries).toHaveLength(1);
|
||||
expect(await (await newNote.historyEntries)[0].user).toEqual(user);
|
||||
expect(await newNote.userPermissions).toHaveLength(0);
|
||||
const groupPermissions = await newNote.groupPermissions;
|
||||
expect(groupPermissions).toHaveLength(2);
|
||||
expect(groupPermissions[0].canEdit).toEqual(
|
||||
everyoneDefaultAccessPermission === DefaultAccessPermission.WRITE,
|
||||
);
|
||||
expect((await groupPermissions[0].group).name).toEqual(
|
||||
SpecialGroup.EVERYONE,
|
||||
);
|
||||
expect(groupPermissions[1].canEdit).toEqual(
|
||||
loggedinDefaultAccessPermission === DefaultAccessPermission.WRITE,
|
||||
);
|
||||
expect((await groupPermissions[1].group).name).toEqual(
|
||||
SpecialGroup.LOGGED_IN,
|
||||
);
|
||||
expect(await newNote.tags).toHaveLength(0);
|
||||
expect(await newNote.owner).toEqual(user);
|
||||
expect(await newNote.aliases).toHaveLength(1);
|
||||
expect((await newNote.aliases)[0].name).toEqual(alias);
|
||||
});
|
||||
});
|
||||
describe('with other', () => {
|
||||
beforeEach(
|
||||
() =>
|
||||
(noteMockConfig.permissions.default.everyone =
|
||||
DefaultAccessPermission.NONE),
|
||||
);
|
||||
it('default permissions', async () => {
|
||||
mockGroupRepo();
|
||||
const newNote = await service.createNote(content, user, alias);
|
||||
const revisions = await newNote.revisions;
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(revisions[0].content).toEqual(content);
|
||||
expect(await newNote.historyEntries).toHaveLength(1);
|
||||
expect(await (await newNote.historyEntries)[0].user).toEqual(user);
|
||||
expect(await newNote.userPermissions).toHaveLength(0);
|
||||
const groupPermissions = await newNote.groupPermissions;
|
||||
expect(groupPermissions).toHaveLength(1);
|
||||
expect(groupPermissions[0].canEdit).toEqual(
|
||||
loggedinDefaultAccessPermission === DefaultAccessPermission.WRITE,
|
||||
);
|
||||
expect((await groupPermissions[0].group).name).toEqual(
|
||||
SpecialGroup.LOGGED_IN,
|
||||
);
|
||||
expect(await newNote.tags).toHaveLength(0);
|
||||
expect(await newNote.owner).toEqual(user);
|
||||
expect(await newNote.aliases).toHaveLength(1);
|
||||
expect((await newNote.aliases)[0].name).toEqual(alias);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('fails:', () => {
|
||||
it('alias is forbidden', async () => {
|
||||
await expect(
|
||||
service.createNote(content, null, forbiddenNoteId),
|
||||
).rejects.toThrow(ForbiddenIdError);
|
||||
});
|
||||
|
||||
it('alias is already used', async () => {
|
||||
mockGroupRepo();
|
||||
jest.spyOn(noteRepo, 'save').mockImplementationOnce(async () => {
|
||||
throw new Error();
|
||||
});
|
||||
await expect(service.createNote(content, null, alias)).rejects.toThrow(
|
||||
AlreadyInDBError,
|
||||
);
|
||||
});
|
||||
describe('with maxDocumentLength 1000', () => {
|
||||
beforeEach(() => (noteMockConfig.maxDocumentLength = 1000));
|
||||
it('document is too long', async () => {
|
||||
mockGroupRepo();
|
||||
jest.spyOn(noteRepo, 'save').mockImplementationOnce(async () => {
|
||||
throw new Error();
|
||||
});
|
||||
const content = 'x'.repeat(noteMockConfig.maxDocumentLength + 1);
|
||||
await expect(
|
||||
service.createNote(content, user, alias),
|
||||
).rejects.toThrow(MaximumDocumentLengthExceededError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNoteContent', () => {
|
||||
it('works', async () => {
|
||||
const content = 'testContent';
|
||||
jest
|
||||
.spyOn(noteRepo, 'save')
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.mockImplementation(async (note: Note): Promise<Note> => note);
|
||||
mockGroupRepo();
|
||||
const newNote = await service.createNote(content, null);
|
||||
const revisions = await newNote.revisions;
|
||||
jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(revisions[0]);
|
||||
await service.getNoteContent(newNote).then((result) => {
|
||||
expect(result).toEqual(content);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNoteByIdOrAlias', () => {
|
||||
it('works', async () => {
|
||||
const user = User.create('hardcoded', 'Testy') as User;
|
||||
const note = Note.create(user);
|
||||
const createQueryBuilder = {
|
||||
leftJoinAndSelect: () => createQueryBuilder,
|
||||
where: () => createQueryBuilder,
|
||||
orWhere: () => createQueryBuilder,
|
||||
setParameter: () => createQueryBuilder,
|
||||
getOne: () => note,
|
||||
};
|
||||
jest
|
||||
.spyOn(noteRepo, 'createQueryBuilder')
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.mockImplementation(() => createQueryBuilder);
|
||||
const foundNote = await service.getNoteByIdOrAlias('noteThatExists');
|
||||
expect(foundNote).toEqual(note);
|
||||
});
|
||||
describe('fails:', () => {
|
||||
it('no note found', async () => {
|
||||
const createQueryBuilder = {
|
||||
leftJoinAndSelect: () => createQueryBuilder,
|
||||
where: () => createQueryBuilder,
|
||||
orWhere: () => createQueryBuilder,
|
||||
setParameter: () => createQueryBuilder,
|
||||
getOne: () => null,
|
||||
};
|
||||
jest
|
||||
.spyOn(noteRepo, 'createQueryBuilder')
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.mockImplementation(() => createQueryBuilder);
|
||||
await expect(
|
||||
service.getNoteByIdOrAlias('noteThatDoesNoteExist'),
|
||||
).rejects.toThrow(NotInDBError);
|
||||
});
|
||||
it('id is forbidden', async () => {
|
||||
await expect(
|
||||
service.getNoteByIdOrAlias(forbiddenNoteId),
|
||||
).rejects.toThrow(ForbiddenIdError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteNote', () => {
|
||||
it('works', async () => {
|
||||
const user = User.create('hardcoded', 'Testy') as User;
|
||||
const note = Note.create(user) as Note;
|
||||
jest
|
||||
.spyOn(noteRepo, 'remove')
|
||||
.mockImplementationOnce(async (entry, _) => {
|
||||
expect(entry).toEqual(note);
|
||||
return entry;
|
||||
});
|
||||
const mockedEventEmitter = jest
|
||||
.spyOn(eventEmitter, 'emit')
|
||||
.mockImplementationOnce((event) => {
|
||||
expect(event).toEqual(NoteEvent.DELETION);
|
||||
return true;
|
||||
});
|
||||
expect(mockedEventEmitter).not.toHaveBeenCalled();
|
||||
await service.deleteNote(note);
|
||||
expect(mockedEventEmitter).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateNote', () => {
|
||||
it('works', async () => {
|
||||
const [note, ,] = await getMockData();
|
||||
const revisionLength = (await note.revisions).length;
|
||||
const updatedNote = await service.updateNote(note, 'newContent');
|
||||
expect(await updatedNote.revisions).toHaveLength(revisionLength + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toTagList', () => {
|
||||
it('works', async () => {
|
||||
const note = {} as Note;
|
||||
note.tags = Promise.resolve([
|
||||
{
|
||||
id: 1,
|
||||
name: 'testTag',
|
||||
notes: Promise.resolve([note]),
|
||||
},
|
||||
]);
|
||||
const tagList = await service.toTagList(note);
|
||||
expect(tagList).toHaveLength(1);
|
||||
expect(tagList[0]).toEqual((await note.tags)[0].name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toNotePermissionsDto', () => {
|
||||
it('works', async () => {
|
||||
const [note, user, group] = await getMockData();
|
||||
const permissions = await service.toNotePermissionsDto(note);
|
||||
expect(permissions.owner).toEqual(user.username);
|
||||
expect(permissions.sharedToUsers).toHaveLength(1);
|
||||
expect(permissions.sharedToUsers[0].username).toEqual(user.username);
|
||||
expect(permissions.sharedToUsers[0].canEdit).toEqual(true);
|
||||
expect(permissions.sharedToGroups).toHaveLength(1);
|
||||
expect(permissions.sharedToGroups[0].groupName).toEqual(
|
||||
group.displayName,
|
||||
);
|
||||
expect(permissions.sharedToGroups[0].canEdit).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toNoteMetadataDto', () => {
|
||||
it('works', async () => {
|
||||
const [note, user, group] = await getMockData();
|
||||
note.aliases = Promise.resolve([
|
||||
Alias.create('testAlias', note, true) as Alias,
|
||||
]);
|
||||
|
||||
const metadataDto = await service.toNoteMetadataDto(note);
|
||||
expect(metadataDto.id).toEqual(note.publicId);
|
||||
expect(metadataDto.aliases).toHaveLength(1);
|
||||
expect(metadataDto.aliases[0].name).toEqual((await note.aliases)[0].name);
|
||||
expect(metadataDto.primaryAddress).toEqual('testAlias');
|
||||
expect(metadataDto.title).toEqual(note.title);
|
||||
expect(metadataDto.description).toEqual(note.description);
|
||||
expect(metadataDto.editedBy).toHaveLength(1);
|
||||
expect(metadataDto.editedBy[0]).toEqual(user.username);
|
||||
expect(metadataDto.permissions.owner).toEqual(user.username);
|
||||
expect(metadataDto.permissions.sharedToUsers).toHaveLength(1);
|
||||
expect(metadataDto.permissions.sharedToUsers[0].username).toEqual(
|
||||
user.username,
|
||||
);
|
||||
expect(metadataDto.permissions.sharedToUsers[0].canEdit).toEqual(true);
|
||||
expect(metadataDto.permissions.sharedToGroups).toHaveLength(1);
|
||||
expect(metadataDto.permissions.sharedToGroups[0].groupName).toEqual(
|
||||
group.displayName,
|
||||
);
|
||||
expect(metadataDto.permissions.sharedToGroups[0].canEdit).toEqual(true);
|
||||
expect(metadataDto.tags).toHaveLength(1);
|
||||
expect(metadataDto.tags[0]).toEqual((await note.tags)[0].name);
|
||||
expect(metadataDto.updatedAt).toEqual(
|
||||
(await note.revisions)[0].createdAt,
|
||||
);
|
||||
expect(metadataDto.updateUsername).toEqual(user.username);
|
||||
expect(metadataDto.viewCount).toEqual(note.viewCount);
|
||||
});
|
||||
it('returns publicId if no alias exists', async () => {
|
||||
const [note, ,] = await getMockData();
|
||||
const metadataDto = await service.toNoteMetadataDto(note);
|
||||
expect(metadataDto.primaryAddress).toEqual(note.publicId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toNoteDto', () => {
|
||||
it('works', async () => {
|
||||
const [note, user, group] = await getMockData();
|
||||
note.aliases = Promise.resolve([
|
||||
Alias.create('testAlias', note, true) as Alias,
|
||||
]);
|
||||
|
||||
const noteDto = await service.toNoteDto(note);
|
||||
expect(noteDto.metadata.id).toEqual(note.publicId);
|
||||
expect(noteDto.metadata.aliases).toHaveLength(1);
|
||||
expect(noteDto.metadata.aliases[0].name).toEqual(
|
||||
(await note.aliases)[0].name,
|
||||
);
|
||||
expect(noteDto.metadata.title).toEqual(note.title);
|
||||
expect(noteDto.metadata.description).toEqual(note.description);
|
||||
expect(noteDto.metadata.editedBy).toHaveLength(1);
|
||||
expect(noteDto.metadata.editedBy[0]).toEqual(user.username);
|
||||
expect(noteDto.metadata.permissions.owner).toEqual(user.username);
|
||||
expect(noteDto.metadata.permissions.sharedToUsers).toHaveLength(1);
|
||||
expect(noteDto.metadata.permissions.sharedToUsers[0].username).toEqual(
|
||||
user.username,
|
||||
);
|
||||
expect(noteDto.metadata.permissions.sharedToUsers[0].canEdit).toEqual(
|
||||
true,
|
||||
);
|
||||
expect(noteDto.metadata.permissions.sharedToGroups).toHaveLength(1);
|
||||
expect(noteDto.metadata.permissions.sharedToGroups[0].groupName).toEqual(
|
||||
group.displayName,
|
||||
);
|
||||
expect(noteDto.metadata.permissions.sharedToGroups[0].canEdit).toEqual(
|
||||
true,
|
||||
);
|
||||
expect(noteDto.metadata.tags).toHaveLength(1);
|
||||
expect(noteDto.metadata.tags[0]).toEqual((await note.tags)[0].name);
|
||||
expect(noteDto.metadata.updateUsername).toEqual(user.username);
|
||||
expect(noteDto.metadata.viewCount).toEqual(note.viewCount);
|
||||
expect(noteDto.content).toEqual('testContent');
|
||||
});
|
||||
});
|
||||
});
|
391
backend/src/notes/notes.service.ts
Normal file
391
backend/src/notes/notes.service.ts
Normal file
|
@ -0,0 +1,391 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Optional } from '@mrdrogdrog/optional';
|
||||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { DefaultAccessPermission } from '../config/default-access-permission.enum';
|
||||
import noteConfiguration, { NoteConfig } from '../config/note.config';
|
||||
import {
|
||||
AlreadyInDBError,
|
||||
ForbiddenIdError,
|
||||
MaximumDocumentLengthExceededError,
|
||||
NotInDBError,
|
||||
} from '../errors/errors';
|
||||
import { NoteEvent } from '../events';
|
||||
import { Group } from '../groups/group.entity';
|
||||
import { GroupsService } from '../groups/groups.service';
|
||||
import { HistoryEntry } from '../history/history-entry.entity';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
|
||||
import { RealtimeNoteStore } from '../realtime/realtime-note/realtime-note-store';
|
||||
import { RealtimeNoteService } from '../realtime/realtime-note/realtime-note.service';
|
||||
import { Revision } from '../revisions/revision.entity';
|
||||
import { RevisionsService } from '../revisions/revisions.service';
|
||||
import { User } from '../users/user.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { Alias } from './alias.entity';
|
||||
import { AliasService } from './alias.service';
|
||||
import { NoteMetadataDto } from './note-metadata.dto';
|
||||
import { NotePermissionsDto } from './note-permissions.dto';
|
||||
import { NoteDto } from './note.dto';
|
||||
import { Note } from './note.entity';
|
||||
import { Tag } from './tag.entity';
|
||||
import { getPrimaryAlias } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class NotesService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@InjectRepository(Note) private noteRepository: Repository<Note>,
|
||||
@InjectRepository(Tag) private tagRepository: Repository<Tag>,
|
||||
@InjectRepository(Alias) private aliasRepository: Repository<Alias>,
|
||||
@InjectRepository(User) private userRepository: Repository<User>,
|
||||
@Inject(UsersService) private usersService: UsersService,
|
||||
@Inject(GroupsService) private groupsService: GroupsService,
|
||||
private revisionsService: RevisionsService,
|
||||
@Inject(noteConfiguration.KEY)
|
||||
private noteConfig: NoteConfig,
|
||||
@Inject(forwardRef(() => AliasService)) private aliasService: AliasService,
|
||||
private realtimeNoteService: RealtimeNoteService,
|
||||
private realtimeNoteStore: RealtimeNoteStore,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {
|
||||
this.logger.setContext(NotesService.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Get all notes owned by a user.
|
||||
* @param {User} user - the user who owns the notes
|
||||
* @return {Note[]} arary of notes owned by the user
|
||||
*/
|
||||
async getUserNotes(user: User): Promise<Note[]> {
|
||||
const notes = await this.noteRepository
|
||||
.createQueryBuilder('note')
|
||||
.where('note.owner = :user', { user: user.id })
|
||||
.getMany();
|
||||
if (notes === null) {
|
||||
return [];
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Create a new note.
|
||||
* @param {string} noteContent - the content the new note should have
|
||||
* @param {string=} alias - an optional alias the note should have
|
||||
* @param {User=} owner - the owner of the note
|
||||
* @return {Note} the newly created note
|
||||
* @throws {AlreadyInDBError} a note with the requested id or alias already exists
|
||||
* @throws {ForbiddenIdError} the requested id or alias is forbidden
|
||||
* @throws {MaximumDocumentLengthExceededError} the noteContent is longer than the maxDocumentLength
|
||||
*/
|
||||
async createNote(
|
||||
noteContent: string,
|
||||
owner: User | null,
|
||||
alias?: string,
|
||||
): Promise<Note> {
|
||||
if (alias) {
|
||||
this.checkNoteIdOrAlias(alias);
|
||||
}
|
||||
const newNote = Note.create(owner, alias);
|
||||
if (noteContent.length > this.noteConfig.maxDocumentLength) {
|
||||
throw new MaximumDocumentLengthExceededError();
|
||||
}
|
||||
//TODO: Calculate patch
|
||||
newNote.revisions = Promise.resolve([
|
||||
Revision.create(noteContent, noteContent, newNote as Note) as Revision,
|
||||
]);
|
||||
if (owner) {
|
||||
newNote.historyEntries = Promise.resolve([
|
||||
HistoryEntry.create(owner, newNote as Note) as HistoryEntry,
|
||||
]);
|
||||
}
|
||||
|
||||
const everyonePermission = this.createGroupPermission(
|
||||
newNote as Note,
|
||||
await this.groupsService.getEveryoneGroup(),
|
||||
owner === null
|
||||
? DefaultAccessPermission.WRITE
|
||||
: this.noteConfig.permissions.default.everyone,
|
||||
);
|
||||
|
||||
const loggedInPermission = this.createGroupPermission(
|
||||
newNote as Note,
|
||||
await this.groupsService.getLoggedInGroup(),
|
||||
this.noteConfig.permissions.default.loggedIn,
|
||||
);
|
||||
|
||||
newNote.groupPermissions = Promise.resolve([
|
||||
...Optional.ofNullable(everyonePermission).wrapInArray(),
|
||||
...Optional.ofNullable(loggedInPermission).wrapInArray(),
|
||||
]);
|
||||
|
||||
try {
|
||||
return await this.noteRepository.save(newNote);
|
||||
} catch (e) {
|
||||
if (alias) {
|
||||
this.logger.debug(
|
||||
`A note with the alias '${alias}' already exists.`,
|
||||
'createNote',
|
||||
);
|
||||
throw new AlreadyInDBError(
|
||||
`A note with the alias '${alias}' already exists.`,
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createGroupPermission(
|
||||
note: Note,
|
||||
group: Group,
|
||||
permission: DefaultAccessPermission,
|
||||
): NoteGroupPermission | null {
|
||||
return permission === DefaultAccessPermission.NONE
|
||||
? null
|
||||
: NoteGroupPermission.create(
|
||||
group,
|
||||
note,
|
||||
permission === DefaultAccessPermission.WRITE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Get the current content of the note.
|
||||
* @param {Note} note - the note to use
|
||||
* @return {string} the content of the note
|
||||
*/
|
||||
async getNoteContent(note: Note): Promise<string> {
|
||||
return (
|
||||
this.realtimeNoteStore.find(note.id)?.getYDoc().getCurrentContent() ??
|
||||
(await this.revisionsService.getLatestRevision(note)).content
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Get a note by either their id or alias.
|
||||
* @param {string} noteIdOrAlias - the notes id or alias
|
||||
* @return {Note} the note
|
||||
* @throws {NotInDBError} there is no note with this id or alias
|
||||
* @throws {ForbiddenIdError} the requested id or alias is forbidden
|
||||
*/
|
||||
async getNoteByIdOrAlias(noteIdOrAlias: string): Promise<Note> {
|
||||
this.logger.debug(
|
||||
`Trying to find note '${noteIdOrAlias}'`,
|
||||
'getNoteByIdOrAlias',
|
||||
);
|
||||
|
||||
this.checkNoteIdOrAlias(noteIdOrAlias);
|
||||
|
||||
/**
|
||||
* This query gets the note's aliases, owner, groupPermissions (and the groups), userPermissions (and the users) and tags and
|
||||
* then only gets the note, that either has a publicId :noteIdOrAlias or has any alias with this name.
|
||||
**/
|
||||
const note = await this.noteRepository
|
||||
.createQueryBuilder('note')
|
||||
.leftJoinAndSelect('note.aliases', 'alias')
|
||||
.leftJoinAndSelect('note.owner', 'owner')
|
||||
.leftJoinAndSelect('note.groupPermissions', 'group_permission')
|
||||
.leftJoinAndSelect('group_permission.group', 'group')
|
||||
.leftJoinAndSelect('note.userPermissions', 'user_permission')
|
||||
.leftJoinAndSelect('user_permission.user', 'user')
|
||||
.leftJoinAndSelect('note.tags', 'tag')
|
||||
.where('note.publicId = :noteIdOrAlias')
|
||||
.orWhere((queryBuilder) => {
|
||||
const subQuery = queryBuilder
|
||||
.subQuery()
|
||||
.select('alias.noteId')
|
||||
.from(Alias, 'alias')
|
||||
.where('alias.name = :noteIdOrAlias')
|
||||
.getQuery();
|
||||
return 'note.id IN ' + subQuery;
|
||||
})
|
||||
.setParameter('noteIdOrAlias', noteIdOrAlias)
|
||||
.getOne();
|
||||
|
||||
if (note === null) {
|
||||
this.logger.debug(
|
||||
`Could not find note '${noteIdOrAlias}'`,
|
||||
'getNoteByIdOrAlias',
|
||||
);
|
||||
throw new NotInDBError(
|
||||
`Note with id/alias '${noteIdOrAlias}' not found.`,
|
||||
);
|
||||
}
|
||||
this.logger.debug(`Found note '${noteIdOrAlias}'`, 'getNoteByIdOrAlias');
|
||||
return note;
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Get all users that ever appeared as an author for the given note
|
||||
* @param note The note to search authors for
|
||||
*/
|
||||
async getAuthorUsers(note: Note): Promise<User[]> {
|
||||
return await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.innerJoin('user.authors', 'author')
|
||||
.innerJoin('author.edits', 'edit')
|
||||
.innerJoin('edit.revisions', 'revision')
|
||||
.innerJoin('revision.note', 'note')
|
||||
.where('note.id = :id', { id: note.id })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the provided note id or alias is not forbidden
|
||||
* @param noteIdOrAlias - the alias or id in question
|
||||
* @throws {ForbiddenIdError} the requested id or alias is forbidden
|
||||
*/
|
||||
checkNoteIdOrAlias(noteIdOrAlias: string): void {
|
||||
if (this.noteConfig.forbiddenNoteIds.includes(noteIdOrAlias)) {
|
||||
this.logger.debug(
|
||||
`A note with the alias '${noteIdOrAlias}' is forbidden by the administrator.`,
|
||||
'checkNoteIdOrAlias',
|
||||
);
|
||||
throw new ForbiddenIdError(
|
||||
`A note with the alias '${noteIdOrAlias}' is forbidden by the administrator.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Delete a note
|
||||
* @param {Note} note - the note to delete
|
||||
* @return {Note} the note, that was deleted
|
||||
* @throws {NotInDBError} there is no note with this id or alias
|
||||
*/
|
||||
async deleteNote(note: Note): Promise<Note> {
|
||||
this.eventEmitter.emit(NoteEvent.DELETION, note.id);
|
||||
return await this.noteRepository.remove(note);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Update a notes content.
|
||||
* @param {Note} note - the note
|
||||
* @param {string} noteContent - the new content
|
||||
* @return {Note} the note with a new revision and new content
|
||||
* @throws {NotInDBError} there is no note with this id or alias
|
||||
*/
|
||||
async updateNote(note: Note, noteContent: string): Promise<Note> {
|
||||
const revisions = await note.revisions;
|
||||
//TODO: Calculate patch
|
||||
revisions.push(Revision.create(noteContent, noteContent, note) as Revision);
|
||||
note.revisions = Promise.resolve(revisions);
|
||||
return await this.noteRepository.save(note);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Calculate the updateUser (for the NoteDto) for a Note.
|
||||
* @param {Note} note - the note to use
|
||||
* @return {User} user to be used as updateUser in the NoteDto
|
||||
*/
|
||||
async calculateUpdateUser(note: Note): Promise<User | null> {
|
||||
const lastRevision = await this.revisionsService.getLatestRevision(note);
|
||||
const edits = await lastRevision.edits;
|
||||
if (edits.length > 0) {
|
||||
// Sort the last Revisions Edits by their updatedAt Date to get the latest one
|
||||
// the user of that Edit is the updateUser
|
||||
return await (
|
||||
await edits.sort(
|
||||
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(),
|
||||
)[0].author
|
||||
).user;
|
||||
}
|
||||
// If there are no Edits, the owner is the updateUser
|
||||
return await note.owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the tags of a note to a string array of the tags names.
|
||||
* @param {Note} note - the note to use
|
||||
* @return {string[]} string array of tags names
|
||||
*/
|
||||
async toTagList(note: Note): Promise<string[]> {
|
||||
return (await note.tags).map((tag) => tag.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build NotePermissionsDto from a note.
|
||||
* @param {Note} note - the note to use
|
||||
* @return {NotePermissionsDto} the built NotePermissionDto
|
||||
*/
|
||||
async toNotePermissionsDto(note: Note): Promise<NotePermissionsDto> {
|
||||
const owner = await note.owner;
|
||||
const userPermissions = await note.userPermissions;
|
||||
const groupPermissions = await note.groupPermissions;
|
||||
return {
|
||||
owner: owner ? owner.username : null,
|
||||
sharedToUsers: await Promise.all(
|
||||
userPermissions.map(async (noteUserPermission) => ({
|
||||
username: (await noteUserPermission.user).username,
|
||||
canEdit: noteUserPermission.canEdit,
|
||||
})),
|
||||
),
|
||||
sharedToGroups: await Promise.all(
|
||||
groupPermissions.map(async (noteGroupPermission) => ({
|
||||
groupName: (await noteGroupPermission.group).name,
|
||||
canEdit: noteGroupPermission.canEdit,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Build NoteMetadataDto from a note.
|
||||
* @param {Note} note - the note to use
|
||||
* @return {NoteMetadataDto} the built NoteMetadataDto
|
||||
*/
|
||||
async toNoteMetadataDto(note: Note): Promise<NoteMetadataDto> {
|
||||
const updateUser = await this.calculateUpdateUser(note);
|
||||
return {
|
||||
id: note.publicId,
|
||||
aliases: await Promise.all(
|
||||
(
|
||||
await note.aliases
|
||||
).map((alias) => this.aliasService.toAliasDto(alias, note)),
|
||||
),
|
||||
primaryAddress: (await getPrimaryAlias(note)) ?? note.publicId,
|
||||
title: note.title ?? '',
|
||||
createdAt: note.createdAt,
|
||||
description: note.description ?? '',
|
||||
editedBy: (await this.getAuthorUsers(note)).map((user) => user.username),
|
||||
permissions: await this.toNotePermissionsDto(note),
|
||||
tags: await this.toTagList(note),
|
||||
version: note.version,
|
||||
updatedAt: (await this.revisionsService.getLatestRevision(note))
|
||||
.createdAt,
|
||||
updateUsername: updateUser ? updateUser.username : null,
|
||||
viewCount: note.viewCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Build NoteDto from a note.
|
||||
* @param {Note} note - the note to use
|
||||
* @return {NoteDto} the built NoteDto
|
||||
*/
|
||||
async toNoteDto(note: Note): Promise<NoteDto> {
|
||||
return {
|
||||
content: await this.getNoteContent(note),
|
||||
metadata: await this.toNoteMetadataDto(note),
|
||||
editedByAtPosition: [],
|
||||
};
|
||||
}
|
||||
}
|
22
backend/src/notes/primary.value-transformer.ts
Normal file
22
backend/src/notes/primary.value-transformer.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ValueTransformer } from 'typeorm';
|
||||
|
||||
export class PrimaryValueTransformer implements ValueTransformer {
|
||||
from(value: boolean | null): boolean {
|
||||
if (value === null) {
|
||||
return false;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
to(value: boolean): boolean | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
22
backend/src/notes/tag.entity.ts
Normal file
22
backend/src/notes/tag.entity.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
import { Note } from './note.entity';
|
||||
|
||||
@Entity()
|
||||
export class Tag {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({
|
||||
nullable: false,
|
||||
})
|
||||
name: string;
|
||||
|
||||
@ManyToMany((_) => Note, (note) => note.tags)
|
||||
notes: Promise<Note[]>;
|
||||
}
|
40
backend/src/notes/utils.spec.ts
Normal file
40
backend/src/notes/utils.spec.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { User } from '../users/user.entity';
|
||||
import { Alias } from './alias.entity';
|
||||
import { Note } from './note.entity';
|
||||
import { generatePublicId, getPrimaryAlias } from './utils';
|
||||
|
||||
jest.mock('crypto');
|
||||
const random128bitBuffer = Buffer.from([
|
||||
0xe1, 0x75, 0x86, 0xb7, 0xc3, 0xfb, 0x03, 0xa9, 0x26, 0x9f, 0xc9, 0xd6, 0x8c,
|
||||
0x2d, 0x7b, 0x7b,
|
||||
]);
|
||||
const mockRandomBytes = randomBytes as jest.MockedFunction<typeof randomBytes>;
|
||||
mockRandomBytes.mockImplementation((_) => random128bitBuffer);
|
||||
|
||||
it('generatePublicId', () => {
|
||||
expect(generatePublicId()).toEqual('w5trddy3zc1tj9mzs7b8rbbvfc');
|
||||
});
|
||||
|
||||
describe('getPrimaryAlias', () => {
|
||||
const alias = 'alias';
|
||||
let note: Note;
|
||||
beforeEach(() => {
|
||||
const user = User.create('hardcoded', 'Testy') as User;
|
||||
note = Note.create(user, alias) as Note;
|
||||
});
|
||||
it('finds correct primary alias', async () => {
|
||||
(await note.aliases).push(Alias.create('annother', note, false) as Alias);
|
||||
expect(await getPrimaryAlias(note)).toEqual(alias);
|
||||
});
|
||||
it('returns undefined if there is no alias', async () => {
|
||||
(await note.aliases)[0].primary = false;
|
||||
expect(await getPrimaryAlias(note)).toEqual(undefined);
|
||||
});
|
||||
});
|
33
backend/src/notes/utils.ts
Normal file
33
backend/src/notes/utils.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import base32Encode from 'base32-encode';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { Alias } from './alias.entity';
|
||||
import { Note } from './note.entity';
|
||||
|
||||
/**
|
||||
* Generate publicId for a note.
|
||||
* This is a randomly generated 128-bit value encoded with base32-encode using the crockford variant and converted to lowercase.
|
||||
*/
|
||||
export function generatePublicId(): string {
|
||||
const randomId = randomBytes(16);
|
||||
return base32Encode(randomId, 'Crockford').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the primary alias from a aliases of a note
|
||||
* @param {Note} note - the note from which the primary alias should be extracted
|
||||
*/
|
||||
export async function getPrimaryAlias(note: Note): Promise<string | undefined> {
|
||||
const listWithPrimaryAlias = (await note.aliases).filter(
|
||||
(alias: Alias) => alias.primary,
|
||||
);
|
||||
if (listWithPrimaryAlias.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
return listWithPrimaryAlias[0].name;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue