refactor: replace TypeORM with knex.js

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2025-03-14 23:33:29 +01:00
parent 6e151c8a1b
commit c0ce00b3f9
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
242 changed files with 4601 additions and 6871 deletions

View file

@ -33,12 +33,12 @@ exports[`NotesService toNoteDto works 1`] = `
},
],
},
"primaryAddress": "testAlias",
"primaryAlias": "testAlias",
"tags": [
"tag1",
],
"title": "mockTitle",
"updateUsername": "hardcoded",
"lastUpdatedBy": "hardcoded",
"updatedAt": "2019-02-04T20:34:12.000Z",
"version": undefined,
"viewCount": 1337,
@ -76,12 +76,12 @@ exports[`NotesService toNoteMetadataDto works 1`] = `
},
],
},
"primaryAddress": "testAlias",
"primaryAlias": "testAlias",
"tags": [
"tag1",
],
"title": "mockTitle",
"updateUsername": "hardcoded",
"lastUpdatedBy": "hardcoded",
"updatedAt": "2019-02-04T20:34:12.000Z",
"version": undefined,
"viewCount": 1337,

View file

@ -1,313 +0,0 @@
/*
* SPDX-FileCopyrightText: 2024 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 { Mock } from 'ts-mockery';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { ApiToken } from '../api-token/api-token.entity';
import { Identity } from '../auth/identity.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 { User } from '../database/user.entity';
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 { 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 '../sessions/session.entity';
import { UsersModule } from '../users/users.module';
import { mockSelectQueryBuilderInRepo } from '../utils/test-utils/mockSelectQueryBuilder';
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,
);
aliasRepo = new Repository<Alias>(
'',
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),
useValue: aliasRepo,
},
{
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))
.useValue(aliasRepo)
.overrideProvider(getRepositoryToken(User))
.useClass(Repository)
.overrideProvider(getRepositoryToken(ApiToken))
.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, 'existsBy').mockResolvedValueOnce(false);
jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false);
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, 'existsBy').mockResolvedValueOnce(false);
jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false);
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(noteRepo, 'existsBy').mockResolvedValueOnce(false);
jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(true);
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, 'findOneByOrFail')
.mockResolvedValueOnce(alias)
.mockResolvedValueOnce(alias2);
jest
.spyOn(aliasRepo, 'save')
.mockImplementationOnce(async (alias: Alias): Promise<Alias> => alias)
.mockImplementationOnce(async (alias: Alias): Promise<Alias> => alias);
mockSelectQueryBuilderInRepo(
noteRepo,
Mock.of<Note>({
...note,
aliases: Promise.resolve(
(await note.aliases).map((anAlias) => {
if (anAlias.primary) {
anAlias.primary = false;
}
if (anAlias.name === alias2.name) {
anAlias.primary = true;
}
return anAlias;
}),
),
}),
);
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);
});
});

View file

@ -1,178 +0,0 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { AliasDto } from '@hedgedoc/commons';
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
NotInDBError,
PrimaryAliasDeletionForbiddenError,
} from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
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> {
await this.notesService.ensureNoteIdOrAliasIsAvailable(alias);
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;
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.findOneByOrFail({
id: oldPrimaryId,
});
const newPrimary = await this.aliasRepository.findOneByOrFail({
id: newPrimaryId,
});
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> {
const primaryAlias = await getPrimaryAlias(note);
const noteAliases = await note.aliases;
if (primaryAlias === alias && noteAliases.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: Alias[] = [];
let aliasToDelete: Alias | null = null;
let aliasFound = false;
for (const anAlias of noteAliases) {
if (anAlias.name === alias) {
aliasFound = true;
aliasToDelete = anAlias;
} else {
filteredAliases.push(anAlias);
}
}
if (!aliasFound) {
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.`,
);
}
if (aliasToDelete !== null) {
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,
};
}
}

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { forwardRef, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { KnexModule } from 'nest-knexjs';
import { AliasModule } from '../alias/alias.module';
import { GroupsModule } from '../groups/groups.module';
import { LoggerModule } from '../logger/logger.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module';
import { RevisionsModule } from '../revisions/revisions.module';
import { UsersModule } from '../users/users.module';
import { NoteService } from './note.service';
@Module({
imports: [
AliasModule,
RevisionsModule,
UsersModule,
GroupsModule,
LoggerModule,
forwardRef(() => PermissionsModule),
ConfigModule,
RealtimeNoteModule,
KnexModule,
],
controllers: [],
providers: [NoteService],
exports: [NoteService],
})
export class NoteModule {}

View file

@ -15,6 +15,7 @@ import {
Repository,
} from 'typeorm';
import { AliasService } from '../alias/alias.service';
import { ApiToken } from '../api-token/api-token.entity';
import { Identity } from '../auth/identity.entity';
import { Author } from '../authors/author.entity';
@ -49,16 +50,15 @@ import { RevisionsService } from '../revisions/revisions.service';
import { Session } from '../sessions/session.entity';
import { UsersModule } from '../users/users.module';
import { mockSelectQueryBuilderInRepo } from '../utils/test-utils/mockSelectQueryBuilder';
import { Alias } from './alias.entity';
import { AliasService } from './alias.service';
import { Alias } from './aliases.entity';
import { Note } from './note.entity';
import { NotesService } from './notes.service';
import { NoteService } from './note.service';
import { Tag } from './tag.entity';
jest.mock('../revisions/revisions.service');
describe('NotesService', () => {
let service: NotesService;
let service: NoteService;
let revisionsService: RevisionsService;
const noteMockConfig: NoteConfig = createDefaultMockNoteConfig();
let noteRepo: Repository<Note>;
@ -137,7 +137,7 @@ describe('NotesService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
NotesService,
NoteService,
{
provide: RevisionsService,
useValue: revisionsService,
@ -219,7 +219,7 @@ describe('NotesService', () => {
forbiddenNoteId = noteConfig.forbiddenNoteIds[0];
everyoneDefaultAccessPermission = noteConfig.permissions.default.everyone;
loggedinDefaultAccessPermission = noteConfig.permissions.default.loggedIn;
service = module.get<NotesService>(NotesService);
service = module.get<NoteService>(NoteService);
noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note));
aliasRepo = module.get<Repository<Alias>>(getRepositoryToken(Alias));
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
@ -335,19 +335,19 @@ describe('NotesService', () => {
it('with no note', async () => {
mockSelectQueryBuilderInRepo(noteRepo, null);
const notes = await service.getUserNotes(user);
const notes = await service.getUserNoteIds(user);
expect(notes).toEqual([]);
});
it('with one note', async () => {
mockSelectQueryBuilderInRepo(noteRepo, note);
const notes = await service.getUserNotes(user);
const notes = await service.getUserNoteIds(user);
expect(notes).toEqual([note]);
});
it('with multiple note', async () => {
mockSelectQueryBuilderInRepo(noteRepo, [note, note]);
const notes = await service.getUserNotes(user);
const notes = await service.getUserNoteIds(user);
expect(notes).toEqual([note, note]);
});
});
@ -377,7 +377,7 @@ describe('NotesService', () => {
mockSelectQueryBuilderInRepo(noteRepo, null);
});
it('without alias, without owner', async () => {
it('without aliases, without owner', async () => {
const newNote = await service.createNote(content, null);
expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content);
@ -403,7 +403,7 @@ describe('NotesService', () => {
expect(await newNote.owner).toBeNull();
expect(await newNote.aliases).toHaveLength(0);
});
it('without alias, with owner', async () => {
it('without aliases, with owner', async () => {
const newNote = await service.createNote(content, user);
expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content);
expect(await newNote.revisions).toStrictEqual([newRevision]);
@ -429,7 +429,7 @@ describe('NotesService', () => {
expect(await newNote.owner).toEqual(user);
expect(await newNote.aliases).toHaveLength(0);
});
it('with alias, without owner', async () => {
it('with aliases, without owner', async () => {
const newNote = await service.createNote(content, null, alias);
expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content);
expect(await newNote.revisions).toStrictEqual([newRevision]);
@ -454,7 +454,7 @@ describe('NotesService', () => {
expect(await newNote.owner).toBeNull();
expect(await newNote.aliases).toHaveLength(1);
});
it('with alias, with owner', async () => {
it('with aliases, with owner', async () => {
const newNote = await service.createNote(content, user, alias);
expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content);
@ -549,7 +549,7 @@ describe('NotesService', () => {
mockSelectQueryBuilderInRepo(noteRepo, null);
});
it('alias is forbidden', async () => {
it('aliases is forbidden', async () => {
jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false);
jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false);
await expect(
@ -557,7 +557,7 @@ describe('NotesService', () => {
).rejects.toThrow(ForbiddenIdError);
});
it('alias is already used (as another alias)', async () => {
it('aliases is already used (as another aliases)', async () => {
mockGroupRepo();
jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false);
jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(true);
@ -569,7 +569,7 @@ describe('NotesService', () => {
);
});
it('alias is already used (as publicId)', async () => {
it('aliases is already used (as publicId)', async () => {
mockGroupRepo();
jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(true);
jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false);
@ -614,20 +614,20 @@ describe('NotesService', () => {
const user = User.create('hardcoded', 'Testy') as User;
const note = Note.create(user) as Note;
mockSelectQueryBuilderInRepo(noteRepo, note);
const foundNote = await service.getNoteByIdOrAlias('noteThatExists');
const foundNote = await service.getNoteIdByAlias('noteThatExists');
expect(foundNote).toEqual(note);
});
describe('fails:', () => {
it('no note found', async () => {
mockSelectQueryBuilderInRepo(noteRepo, null);
await expect(
service.getNoteByIdOrAlias('noteThatDoesNoteExist'),
service.getNoteIdByAlias('noteThatDoesNoteExist'),
).rejects.toThrow(NotInDBError);
});
it('id is forbidden', async () => {
await expect(
service.getNoteByIdOrAlias(forbiddenNoteId),
).rejects.toThrow(ForbiddenIdError);
await expect(service.getNoteIdByAlias(forbiddenNoteId)).rejects.toThrow(
ForbiddenIdError,
);
});
});
});
@ -704,10 +704,10 @@ describe('NotesService', () => {
expect(metadataDto).toMatchSnapshot();
});
it('returns publicId if no alias exists', async () => {
it('returns publicId if no aliases exists', async () => {
const [note, ,] = await getMockData();
const metadataDto = await service.toNoteMetadataDto(note);
expect(metadataDto.primaryAddress).toEqual(note.publicId);
expect(metadataDto.primaryAlias).toEqual(note.publicId);
});
});

View file

@ -0,0 +1,466 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
NoteDto,
NoteMetadataDto,
NotePermissionsDto,
SpecialGroup,
} from '@hedgedoc/commons';
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Knex } from 'knex';
import { InjectConnection } from 'nest-knexjs';
import { AliasService } from '../alias/alias.service';
import { DefaultAccessLevel } from '../config/default-access-level.enum';
import noteConfiguration, { NoteConfig } from '../config/note.config';
import {
FieldNameAlias,
FieldNameGroup,
FieldNameNote,
FieldNameNoteGroupPermission,
FieldNameNoteUserPermission,
FieldNameRevision,
FieldNameUser,
Group,
Note,
NoteGroupPermission,
NoteUserPermission,
TableAlias,
TableGroup,
TableNote,
TableNoteGroupPermission,
TableNoteUserPermission,
TableUser,
User,
} from '../database/types';
import {
ForbiddenIdError,
GenericDBError,
MaximumDocumentLengthExceededError,
NotInDBError,
} from '../errors/errors';
import { NoteEventMap } from '../events';
import { GroupsService } from '../groups/groups.service';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { PermissionService } from '../permissions/permission.service';
import { RealtimeNoteStore } from '../realtime/realtime-note/realtime-note-store';
import { RealtimeNoteService } from '../realtime/realtime-note/realtime-note.service';
import { RevisionsService } from '../revisions/revisions.service';
import { UsersService } from '../users/users.service';
@Injectable()
export class NoteService {
constructor(
@InjectConnection()
private readonly knex: Knex,
private readonly logger: ConsoleLoggerService,
@Inject(UsersService) private usersService: UsersService,
@Inject(GroupsService) private groupsService: GroupsService,
private revisionsService: RevisionsService,
@Inject(noteConfiguration.KEY)
private noteConfig: NoteConfig,
@Inject(AliasService)
private aliasService: AliasService,
@Inject(forwardRef(() => PermissionService))
private permissionService: PermissionService,
private realtimeNoteService: RealtimeNoteService,
private realtimeNoteStore: RealtimeNoteStore,
private eventEmitter: EventEmitter2<NoteEventMap>,
) {
this.logger.setContext(NoteService.name);
}
/**
* Get all notes owned by a user
*
* @param userId The id of the user who owns the notes
* @return Array of notes owned by the user
*/
async getUserNoteIds(userId: number): Promise<number[]> {
const result = await this.knex(TableNote)
.select(FieldNameNote.id)
.where(FieldNameNote.ownerId, userId);
return result.map((row) => row[FieldNameNote.id]);
}
/**
* Creates a new note
*
* @param noteContent The content of the new note, in most cases an empty string
* @param givenAlias An optional alias the note should have
* @param ownerUserId The owner of the note
* @return The newly created note
* @throws {AlreadyInDBError} a note with the requested id or aliases already exists
* @throws {ForbiddenIdError} the requested id or aliases is forbidden
* @throws {MaximumDocumentLengthExceededError} the noteContent is longer than the maxDocumentLength
* @thorws {GenericDBError} the database returned a non-expected value
*/
async createNote(
noteContent: string,
ownerUserId: number,
givenAlias?: string,
): Promise<number> {
// Check if new note doesn't violate application constraints
if (noteContent.length > this.noteConfig.maxDocumentLength) {
throw new MaximumDocumentLengthExceededError();
}
return await this.knex.transaction(async (transaction) => {
// Create note itself in the database
const createdNotes = await transaction(TableNote).insert(
{
[FieldNameNote.ownerId]: ownerUserId,
[FieldNameNote.version]: 2,
},
[FieldNameNote.id],
);
if (createdNotes.length !== 1) {
throw new GenericDBError(
'The note could not be created in the database',
this.logger.getContext(),
'createNote',
);
}
const noteId = createdNotes[0][FieldNameNote.id];
if (givenAlias !== undefined) {
await this.aliasService.ensureAliasIsAvailable(givenAlias, transaction);
}
const newAlias =
givenAlias === undefined
? this.aliasService.generateRandomAlias()
: givenAlias;
await this.aliasService.addAlias(noteId, newAlias, transaction);
await this.revisionsService.createRevision(
noteId,
noteContent,
transaction,
);
const isUserRegistered = await this.usersService.isRegisteredUser(
ownerUserId,
transaction,
);
const everyoneAccessLevel = isUserRegistered
? // Use the default access level from the config for registered users
this.noteConfig.permissions.default.everyone
: // If the owner is a guest, this is an anonymous note
// Anonymous notes are always writeable by everyone
DefaultAccessLevel.WRITE;
const loggedInUsersAccessLevel =
this.noteConfig.permissions.default.loggedIn;
if (everyoneAccessLevel !== DefaultAccessLevel.NONE) {
const everyoneAccessGroupId = await this.groupsService.getGroupIdByName(
SpecialGroup.EVERYONE,
);
await this.permissionService.setGroupPermission(
noteId,
everyoneAccessGroupId,
everyoneAccessLevel === DefaultAccessLevel.WRITE,
transaction,
);
}
if (loggedInUsersAccessLevel !== DefaultAccessLevel.NONE) {
const loggedInUsersAccessGroupId =
await this.groupsService.getGroupIdByName(SpecialGroup.LOGGED_IN);
await this.permissionService.setGroupPermission(
noteId,
loggedInUsersAccessGroupId,
loggedInUsersAccessLevel === DefaultAccessLevel.WRITE,
transaction,
);
}
return noteId;
});
}
/**
* Get the current content of the note
*
* @param noteId the note to use
* @param transaction The optional database transaction to use
* @throws {NotInDBError} the note is not in the DB
* @return {string} the content of the note
*/
async getNoteContent(noteId: number, transaction?: Knex): Promise<string> {
const realtimeContent = this.realtimeNoteStore
.find(noteId)
?.getRealtimeDoc()
.getCurrentContent();
if (realtimeContent) {
return realtimeContent;
}
const latestRevision = await this.revisionsService.getLatestRevision(
noteId,
transaction,
);
return latestRevision.content;
}
/**
* Get a note by either their id or aliases
*
* @param alias the notes id or aliases
* @param transaction The optional database transaction to use
* @return the note id
* @throws {NotInDBError} there is no note with this id or aliases
* @throws {ForbiddenIdError} the requested id or aliases is forbidden
*/
async getNoteIdByAlias(alias: string, transaction?: Knex): Promise<number> {
const dbActor = transaction ?? this.knex;
const isForbidden = this.aliasService.isAliasForbidden(alias);
if (isForbidden) {
throw new ForbiddenIdError(
`The note id or alias '${alias}' is forbidden by the administrator.`,
);
}
this.logger.debug(`Trying to find note '${alias}'`, 'getNoteIdByAlias');
/*
* This query gets the note's aliases, owner, groupPermissions (and the groups), userPermissions (and the users) and tags and
* then only selects the note, that has a alias with this name.
*/
const note = await dbActor(TableAlias)
.select<Pick<Note, FieldNameNote.id>>(`${TableNote}.${FieldNameNote.id}`)
.where(FieldNameAlias.alias, alias)
.join(
TableNote,
`${TableAlias}.${FieldNameAlias.noteId}`,
`${TableNote}.${FieldNameNote.id}`,
)
.first();
if (note === undefined) {
const message = `Could not find note '${alias}'`;
this.logger.debug(message, 'getNoteIdByAlias');
throw new NotInDBError(message);
}
this.logger.debug(`Found note '${alias}'`, 'getNoteIdByAlias');
return note[FieldNameNote.id];
}
/**
* Deletes a note
*
* @param noteId If of the note to delete
* @throws {NotInDBError} if there is no note with this id
*/
async deleteNote(noteId: Note[FieldNameNote.id]): Promise<void> {
// TODO Disconnect realtime clients first
const numberOfDeletedNotes = await this.knex(TableNote)
.where(FieldNameNote.id, noteId)
.delete();
if (numberOfDeletedNotes === 0) {
throw new NotInDBError(`There is no note with the to delete.`);
}
// TODO Message realtime clients
}
/**
*
* Update the content of a note
*
* @param noteId - the note
* @param noteContent - the new content
* @return the note with a new revision and new content
* @throws {NotInDBError} there is no note with this id or aliases
*/
async updateNote(noteId: number, noteContent: string): Promise<void> {
// TODO Disconnect realtime clients first
await this.revisionsService.createRevision(noteId, noteContent);
// TODO Reload realtime note
}
/**
* Build NotePermissionsDto from a note.
* @param noteId The id of the ntoe to get the permissions for
* @param transaction The optional database transaction to use
* @return The built NotePermissionDto
*/
async toNotePermissionsDto(
noteId: number,
transaction?: Knex,
): Promise<NotePermissionsDto> {
if (transaction === undefined) {
return await this.knex.transaction(async (newTransaction) => {
return await this.innerToNotePermissionsDto(noteId, newTransaction);
});
}
return await this.innerToNotePermissionsDto(noteId, transaction);
}
async innerToNotePermissionsDto(
noteId: number,
transaction: Knex,
): Promise<NotePermissionsDto> {
const ownerUsername = await transaction(TableNote)
.join(
TableUser,
`${TableNote}.${FieldNameNote.ownerId}`,
`${TableUser}.${FieldNameUser.id}`,
)
.select<
Pick<User, FieldNameUser.username>
>(`${TableUser}.${FieldNameUser.username}`)
.where(`${TableNote}.${FieldNameNote.id}`, noteId)
.first();
if (ownerUsername === undefined) {
throw new NotInDBError(
`The note does not exist.`,
this.logger.getContext(),
'toNotePermissionsDto',
);
}
const userPermissions = await transaction(TableNoteUserPermission)
.join(
TableUser,
`${TableNoteUserPermission}.${FieldNameNoteUserPermission.userId}`,
`${TableUser}.${FieldNameUser.id}`,
)
.select<
({ [FieldNameUser.username]: string } & Pick<
NoteUserPermission,
FieldNameNoteUserPermission.canEdit
>)[]
>(`${TableUser}.${FieldNameUser.username}`, `${TableNoteUserPermission}.${FieldNameNoteUserPermission.canEdit}`)
.whereNotNull(`${TableUser}.${FieldNameUser.username}`)
.andWhere(
`${TableNoteUserPermission}.${FieldNameNoteUserPermission.noteId}`,
noteId,
);
const groupPermissions = await transaction(TableNoteGroupPermission)
.join(
TableGroup,
`${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.groupId}`,
`${TableGroup}.${FieldNameGroup.id}`,
)
.select<
(Pick<Group, FieldNameGroup.name> &
Pick<NoteGroupPermission, FieldNameNoteGroupPermission.canEdit>)[]
>(`${TableGroup}.${FieldNameGroup.name}`, `${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.canEdit}`)
.where(
`${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.noteId}`,
noteId,
);
return {
owner: ownerUsername[FieldNameUser.username],
sharedToUsers: userPermissions.map((noteUserPermission) => ({
username: noteUserPermission[FieldNameUser.username],
canEdit: noteUserPermission[FieldNameNoteUserPermission.canEdit],
})),
sharedToGroups: groupPermissions.map((noteGroupPermission) => ({
groupName: noteGroupPermission[FieldNameGroup.name],
canEdit: noteGroupPermission[FieldNameNoteGroupPermission.canEdit],
})),
};
}
/**
* @async
* Build NoteMetadataDto from a note.
* @param noteId The if of the note to get the metadata for
* @param transaction The optional database transaction to use
* @return The built NoteMetadataDto
*/
async toNoteMetadataDto(
noteId: number,
transaction?: Knex,
): Promise<NoteMetadataDto> {
if (transaction === undefined) {
return await this.knex.transaction(async (newTransaction) => {
return await this.innerToNoteMetadataDto(noteId, newTransaction);
});
}
return await this.innerToNoteMetadataDto(noteId, transaction);
}
private async innerToNoteMetadataDto(
noteId: number,
transaction: Knex,
): Promise<NoteMetadataDto> {
const aliases = await this.aliasService.getAllAliases(noteId, transaction);
const primaryAlias = aliases.find(
(alias) => alias[FieldNameAlias.isPrimary],
);
if (primaryAlias === undefined) {
throw new NotInDBError(
'The note has no primary alias.',
this.logger.getContext(),
'toNoteMetadataDto',
);
}
const note = await transaction(TableNote)
.select(FieldNameNote.createdAt, FieldNameNote.version)
.where(FieldNameNote.id, noteId)
.first();
if (note === undefined) {
throw new NotInDBError(
`The note '${primaryAlias[FieldNameAlias.alias]}' does not exist.`,
this.logger.getContext(),
'toNoteMetadataDto',
);
}
const latestRevision = await this.revisionsService.getLatestRevision(
noteId,
transaction,
);
const tags = await this.revisionsService.getTagsByRevisionUuid(
latestRevision[FieldNameRevision.uuid],
transaction,
);
const updateUsers = await this.revisionsService.getRevisionUserInfo(
latestRevision[FieldNameRevision.uuid],
);
updateUsers.users.sort(
(userA, userB) => userB.createdAt.getTime() - userA.createdAt.getTime(),
);
const lastEdit = updateUsers.users[0];
const editedBy = updateUsers.users.map((user) => user.username);
const permissions = await this.toNotePermissionsDto(noteId, transaction);
return {
aliases: aliases.map((alias) => alias[FieldNameAlias.alias]),
primaryAlias: primaryAlias[FieldNameAlias.alias],
title: latestRevision.title,
description: latestRevision.description,
tags: tags,
createdAt: note[FieldNameNote.createdAt].toISOString(),
editedBy: editedBy,
permissions: permissions,
version: note[FieldNameNote.version],
updatedAt: lastEdit.createdAt.toISOString(),
lastUpdatedBy: lastEdit.username,
};
}
/**
* Gets the note data for the note DTO
*
* @param noteId The id of the note to transform
* @return {NoteDto} the built NoteDto
*/
async toNoteDto(noteId: number): Promise<NoteDto> {
return await this.knex.transaction(async (transaction) => {
return {
content: await this.getNoteContent(noteId, transaction),
metadata: await this.toNoteMetadataDto(noteId, transaction),
editedByAtPosition: [],
};
});
}
}

View file

@ -1,45 +0,0 @@
/*
* 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 { User } from '../database/user.entity';
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 { 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 {}

View file

@ -1,455 +0,0 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
NoteDto,
NoteMetadataDto,
NotePermissionsDto,
} from '@hedgedoc/commons';
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 { DefaultAccessLevel } from '../config/default-access-level.enum';
import noteConfiguration, { NoteConfig } from '../config/note.config';
import { User } from '../database/user.entity';
import {
AlreadyInDBError,
ForbiddenIdError,
MaximumDocumentLengthExceededError,
NotInDBError,
} from '../errors/errors';
import { NoteEvent, NoteEventMap } 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 { RevisionsService } from '../revisions/revisions.service';
import { UsersService } from '../users/users.service';
import { Alias } from './alias.entity';
import { AliasService } from './alias.service';
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<NoteEventMap>,
) {
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> {
// Check if new note doesn't violate application constraints
if (alias) {
await this.ensureNoteIdOrAliasIsAvailable(alias);
}
if (noteContent.length > this.noteConfig.maxDocumentLength) {
throw new MaximumDocumentLengthExceededError();
}
// We cast to a note early to keep the later code clean
const newNote = Note.create(owner, alias) as Note;
const newRevision = await this.revisionsService.createRevision(
newNote,
noteContent,
);
newNote.revisions = Promise.resolve(
newRevision === undefined ? [] : [newRevision],
);
let everyoneAccessLevel;
if (owner) {
// When we know an owner, an initial history entry is created
newNote.historyEntries = Promise.resolve([
HistoryEntry.create(owner, newNote) as HistoryEntry,
]);
// Use the default access level from the config
everyoneAccessLevel = this.noteConfig.permissions.default.everyone;
} else {
// If there is no owner, this is an anonymous note
// Anonymous notes are always writeable by everyone
everyoneAccessLevel = DefaultAccessLevel.WRITE;
}
// Create permission object for the 'everyone' group
const everyonePermission = this.createGroupPermission(
newNote,
await this.groupsService.getEveryoneGroup(),
everyoneAccessLevel,
);
// Create permission object for logged-in users
const loggedInPermission = this.createGroupPermission(
newNote,
await this.groupsService.getLoggedInGroup(),
this.noteConfig.permissions.default.loggedIn,
);
// Merge into permissions array
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,
accessLevel: DefaultAccessLevel,
): NoteGroupPermission | null {
if (accessLevel === DefaultAccessLevel.NONE) {
return null;
}
return NoteGroupPermission.create(
group,
note,
accessLevel === DefaultAccessLevel.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)
?.getRealtimeDoc()
.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> {
const isForbidden = this.noteIdOrAliasIsForbidden(noteIdOrAlias);
if (isForbidden) {
throw new ForbiddenIdError(
`The note id or alias '${noteIdOrAlias}' is forbidden by the administrator.`,
);
}
this.logger.debug(
`Trying to find note '${noteIdOrAlias}'`,
'getNoteByIdOrAlias',
);
/**
* 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')
.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 available for notes
* @param noteIdOrAlias - the alias or id in question
* @throws {ForbiddenIdError} the requested id or alias is not available
*/
async ensureNoteIdOrAliasIsAvailable(noteIdOrAlias: string): Promise<void> {
const isForbidden = this.noteIdOrAliasIsForbidden(noteIdOrAlias);
if (isForbidden) {
throw new ForbiddenIdError(
`The note id or alias '${noteIdOrAlias}' is forbidden by the administrator.`,
);
}
const isUsed = await this.noteIdOrAliasIsUsed(noteIdOrAlias);
if (isUsed) {
throw new AlreadyInDBError(
`A note with the id or alias '${noteIdOrAlias}' already exists.`,
);
}
}
/**
* Check if the provided note id or alias is forbidden
* @param noteIdOrAlias - the alias or id in question
* @return {boolean} true if the id or alias is forbidden, false otherwise
*/
noteIdOrAliasIsForbidden(noteIdOrAlias: string): boolean {
const forbidden = this.noteConfig.forbiddenNoteIds.includes(noteIdOrAlias);
if (forbidden) {
this.logger.debug(
`A note with the alias '${noteIdOrAlias}' is forbidden by the administrator.`,
'noteIdOrAliasIsForbidden',
);
}
return forbidden;
}
/**
* @async
* Check if the provided note id or alias is already used
* @param noteIdOrAlias - the alias or id in question
* @return {boolean} true if the id or alias is already used, false otherwise
*/
async noteIdOrAliasIsUsed(noteIdOrAlias: string): Promise<boolean> {
const noteWithPublicIdExists = await this.noteRepository.existsBy({
publicId: noteIdOrAlias,
});
const noteWithAliasExists = await this.aliasRepository.existsBy({
name: noteIdOrAlias,
});
if (noteWithPublicIdExists || noteWithAliasExists) {
this.logger.debug(
`A note with the id or alias '${noteIdOrAlias}' already exists.`,
'noteIdOrAliasIsUsed',
);
return true;
}
return false;
}
/**
* @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;
const newRevision = await this.revisionsService.createRevision(
note,
noteContent,
);
if (newRevision !== undefined) {
revisions.push(newRevision);
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;
}
/**
* 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);
const latestRevision = await this.revisionsService.getLatestRevision(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: latestRevision.title,
description: latestRevision.description,
tags: (await latestRevision.tags).map((tag) => tag.name),
createdAt: note.createdAt.toISOString(),
editedBy: (await this.getAuthorUsers(note)).map((user) => user.username),
permissions: await this.toNotePermissionsDto(note),
version: note.version,
updatedAt: latestRevision.createdAt.toISOString(),
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: [],
};
}
}

View file

@ -1,22 +0,0 @@
/*
* 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;
}
}

View file

@ -1,40 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { randomBytes } from 'crypto';
import { User } from '../database/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);
});
});

View file

@ -1,33 +0,0 @@
/*
* 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;
}