mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-16 16:14:43 -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
35
backend/src/users/session.entity.ts
Normal file
35
backend/src/users/session.entity.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ISession } from 'connect-typeorm';
|
||||
import {
|
||||
Column,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Author } from '../authors/author.entity';
|
||||
|
||||
@Entity()
|
||||
export class Session implements ISession {
|
||||
@PrimaryColumn('varchar', { length: 255 })
|
||||
public id = '';
|
||||
|
||||
@Index()
|
||||
@Column('bigint')
|
||||
public expiredAt = Date.now();
|
||||
|
||||
@Column('text')
|
||||
public json = '';
|
||||
|
||||
@DeleteDateColumn()
|
||||
public destroyedAt?: Date;
|
||||
|
||||
@ManyToOne(() => Author, (author) => author.sessions)
|
||||
author: Promise<Author>;
|
||||
}
|
62
backend/src/users/user-info.dto.ts
Normal file
62
backend/src/users/user-info.dto.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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 UserInfoDto extends BaseDto {
|
||||
/**
|
||||
* The username
|
||||
* @example "john.smith"
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
username: string;
|
||||
|
||||
/**
|
||||
* The display name
|
||||
* @example "John Smith"
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
displayName: string;
|
||||
|
||||
/**
|
||||
* URL of the profile picture
|
||||
* @example "https://hedgedoc.example.com/uploads/johnsmith.png"
|
||||
*/
|
||||
@ApiProperty({
|
||||
format: 'uri',
|
||||
})
|
||||
@IsString()
|
||||
photo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This DTO contains all attributes of the standard UserInfoDto
|
||||
* in addition to the email address.
|
||||
*/
|
||||
export class FullUserInfoDto extends UserInfoDto {
|
||||
/**
|
||||
* Email address of the user
|
||||
* @example "john.smith@example.com"
|
||||
*/
|
||||
@ApiProperty({
|
||||
format: 'email',
|
||||
})
|
||||
@IsString()
|
||||
email: string;
|
||||
}
|
||||
|
||||
export class UserLoginInfoDto extends UserInfoDto {
|
||||
/**
|
||||
* Identifier of the auth provider that was used to log in
|
||||
*/
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
authProvider: string;
|
||||
}
|
10
backend/src/users/user-relation.enum.ts
Normal file
10
backend/src/users/user-relation.enum.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export enum UserRelationEnum {
|
||||
AUTHTOKENS = 'authTokens',
|
||||
IDENTITIES = 'identities',
|
||||
}
|
97
backend/src/users/user.entity.ts
Normal file
97
backend/src/users/user.entity.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToMany,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { AuthToken } from '../auth/auth-token.entity';
|
||||
import { Author } from '../authors/author.entity';
|
||||
import { Group } from '../groups/group.entity';
|
||||
import { HistoryEntry } from '../history/history-entry.entity';
|
||||
import { Identity } from '../identity/identity.entity';
|
||||
import { MediaUpload } from '../media/media-upload.entity';
|
||||
import { Note } from '../notes/note.entity';
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({
|
||||
unique: true,
|
||||
})
|
||||
username: string;
|
||||
|
||||
@Column()
|
||||
displayName: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: 'text',
|
||||
})
|
||||
photo: string | null;
|
||||
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: 'text',
|
||||
})
|
||||
email: string | null;
|
||||
|
||||
@OneToMany((_) => Note, (note) => note.owner)
|
||||
ownedNotes: Promise<Note[]>;
|
||||
|
||||
@OneToMany((_) => AuthToken, (authToken) => authToken.user)
|
||||
authTokens: Promise<AuthToken[]>;
|
||||
|
||||
@OneToMany((_) => Identity, (identity) => identity.user)
|
||||
identities: Promise<Identity[]>;
|
||||
|
||||
@ManyToMany((_) => Group, (group) => group.members)
|
||||
groups: Promise<Group[]>;
|
||||
|
||||
@OneToMany((_) => HistoryEntry, (historyEntry) => historyEntry.user)
|
||||
historyEntries: Promise<HistoryEntry[]>;
|
||||
|
||||
@OneToMany((_) => MediaUpload, (mediaUpload) => mediaUpload.user)
|
||||
mediaUploads: Promise<MediaUpload[]>;
|
||||
|
||||
@OneToMany(() => Author, (author) => author.user)
|
||||
authors: Promise<Author[]>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
public static create(
|
||||
username: string,
|
||||
displayName: string,
|
||||
): Omit<User, 'id' | 'createdAt' | 'updatedAt'> {
|
||||
const newUser = new User();
|
||||
newUser.username = username;
|
||||
newUser.displayName = displayName;
|
||||
newUser.photo = null;
|
||||
newUser.email = null;
|
||||
newUser.ownedNotes = Promise.resolve([]);
|
||||
newUser.authTokens = Promise.resolve([]);
|
||||
newUser.identities = Promise.resolve([]);
|
||||
newUser.groups = Promise.resolve([]);
|
||||
newUser.historyEntries = Promise.resolve([]);
|
||||
newUser.mediaUploads = Promise.resolve([]);
|
||||
newUser.authors = Promise.resolve([]);
|
||||
return newUser;
|
||||
}
|
||||
}
|
20
backend/src/users/users.module.ts
Normal file
20
backend/src/users/users.module.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { Identity } from '../identity/identity.entity';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { Session } from './session.entity';
|
||||
import { User } from './user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User, Identity, Session]), LoggerModule],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
166
backend/src/users/users.service.spec.ts
Normal file
166
backend/src/users/users.service.spec.ts
Normal file
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import appConfigMock from '../config/mock/app.config.mock';
|
||||
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { User } from './user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
describe('UsersService', () => {
|
||||
let service: UsersService;
|
||||
let userRepo: Repository<User>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UsersService,
|
||||
{
|
||||
provide: getRepositoryToken(User),
|
||||
useClass: Repository,
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfigMock],
|
||||
}),
|
||||
LoggerModule,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UsersService>(UsersService);
|
||||
userRepo = module.get<Repository<User>>(getRepositoryToken(User));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('createUser', () => {
|
||||
const username = 'hardcoded';
|
||||
const displayname = 'Testy';
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(userRepo, 'save')
|
||||
.mockImplementationOnce(async (user: User): Promise<User> => user);
|
||||
});
|
||||
it('successfully creates a user', async () => {
|
||||
const user = await service.createUser(username, displayname);
|
||||
expect(user.username).toEqual(username);
|
||||
expect(user.displayName).toEqual(displayname);
|
||||
});
|
||||
it('fails if username is already taken', async () => {
|
||||
// add additional mock implementation for failure
|
||||
jest.spyOn(userRepo, 'save').mockImplementationOnce(() => {
|
||||
throw new Error();
|
||||
});
|
||||
// create first user with username
|
||||
await service.createUser(username, displayname);
|
||||
// attempt to create second user with username
|
||||
await expect(service.createUser(username, displayname)).rejects.toThrow(
|
||||
AlreadyInDBError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUser', () => {
|
||||
it('works', async () => {
|
||||
const username = 'hardcoded';
|
||||
const displayname = 'Testy';
|
||||
const newUser = User.create(username, displayname) as User;
|
||||
jest.spyOn(userRepo, 'remove').mockImplementationOnce(
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async (user: User): Promise<User> => {
|
||||
expect(user).toEqual(newUser);
|
||||
return user;
|
||||
},
|
||||
);
|
||||
await service.deleteUser(newUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changedDisplayName', () => {
|
||||
it('works', async () => {
|
||||
const username = 'hardcoded';
|
||||
const displayname = 'Testy';
|
||||
const user = User.create(username, displayname) as User;
|
||||
const newDisplayName = 'Testy2';
|
||||
jest.spyOn(userRepo, 'save').mockImplementationOnce(
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async (user: User): Promise<User> => {
|
||||
expect(user.displayName).toEqual(newDisplayName);
|
||||
return user;
|
||||
},
|
||||
);
|
||||
await service.changeDisplayName(user, newDisplayName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserByUsername', () => {
|
||||
const username = 'hardcoded';
|
||||
const displayname = 'Testy';
|
||||
const user = User.create(username, displayname) as User;
|
||||
it('works', async () => {
|
||||
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user);
|
||||
const getUser = await service.getUserByUsername(username);
|
||||
expect(getUser.username).toEqual(username);
|
||||
expect(getUser.displayName).toEqual(displayname);
|
||||
});
|
||||
it('fails when user does not exits', async () => {
|
||||
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(null);
|
||||
await expect(service.getUserByUsername(username)).rejects.toThrow(
|
||||
NotInDBError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPhotoUrl', () => {
|
||||
const username = 'hardcoded';
|
||||
const displayname = 'Testy';
|
||||
const user = User.create(username, displayname) as User;
|
||||
it('works if a user has a photoUrl', () => {
|
||||
const photo = 'testPhotoUrl';
|
||||
user.photo = photo;
|
||||
const photoUrl = service.getPhotoUrl(user);
|
||||
expect(photoUrl).toEqual(photo);
|
||||
});
|
||||
it('works if a user no photoUrl', () => {
|
||||
user.photo = undefined;
|
||||
const photoUrl = service.getPhotoUrl(user);
|
||||
expect(photoUrl).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toUserDto', () => {
|
||||
const username = 'hardcoded';
|
||||
const displayname = 'Testy';
|
||||
const user = User.create(username, displayname) as User;
|
||||
it('works if a user is provided', () => {
|
||||
const userDto = service.toUserDto(user);
|
||||
expect(userDto.username).toEqual(username);
|
||||
expect(userDto.displayName).toEqual(displayname);
|
||||
expect(userDto.photo).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toFullUserDto', () => {
|
||||
const username = 'hardcoded';
|
||||
const displayname = 'Testy';
|
||||
const user = User.create(username, displayname) as User;
|
||||
it('works if a user is provided', () => {
|
||||
const userDto = service.toFullUserDto(user);
|
||||
expect(userDto.username).toEqual(username);
|
||||
expect(userDto.displayName).toEqual(displayname);
|
||||
expect(userDto.photo).toEqual('');
|
||||
expect(userDto.email).toEqual('');
|
||||
});
|
||||
});
|
||||
});
|
142
backend/src/users/users.service.ts
Normal file
142
backend/src/users/users.service.ts
Normal file
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import {
|
||||
FullUserInfoDto,
|
||||
UserInfoDto,
|
||||
UserLoginInfoDto,
|
||||
} from './user-info.dto';
|
||||
import { UserRelationEnum } from './user-relation.enum';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@InjectRepository(User) private userRepository: Repository<User>,
|
||||
) {
|
||||
this.logger.setContext(UsersService.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Create a new user with a given username and displayName
|
||||
* @param username - the username the new user shall have
|
||||
* @param displayName - the display name the new user shall have
|
||||
* @return {User} the user
|
||||
* @throws {AlreadyInDBError} the username is already taken.
|
||||
*/
|
||||
async createUser(username: string, displayName: string): Promise<User> {
|
||||
const user = User.create(username, displayName);
|
||||
try {
|
||||
return await this.userRepository.save(user);
|
||||
} catch {
|
||||
this.logger.debug(
|
||||
`A user with the username '${username}' already exists.`,
|
||||
'createUser',
|
||||
);
|
||||
throw new AlreadyInDBError(
|
||||
`A user with the username '${username}' already exists.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Delete the user with the specified username
|
||||
* @param {User} user - the username of the user to be delete
|
||||
* @throws {NotInDBError} the username has no user associated with it.
|
||||
*/
|
||||
async deleteUser(user: User): Promise<void> {
|
||||
await this.userRepository.remove(user);
|
||||
this.logger.debug(
|
||||
`Successfully deleted user with username ${user.username}`,
|
||||
'deleteUser',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Change the displayName of the specified user
|
||||
* @param {User} user - the user to be changed
|
||||
* @param displayName - the new displayName
|
||||
*/
|
||||
async changeDisplayName(user: User, displayName: string): Promise<void> {
|
||||
user.displayName = displayName;
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Get the user specified by the username
|
||||
* @param {string} username the username by which the user is specified
|
||||
* @param {UserRelationEnum[]} [withRelations=[]] if the returned user object should contain certain relations
|
||||
* @return {User} the specified user
|
||||
*/
|
||||
async getUserByUsername(
|
||||
username: string,
|
||||
withRelations: UserRelationEnum[] = [],
|
||||
): Promise<User> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { username: username },
|
||||
relations: withRelations,
|
||||
});
|
||||
if (user === null) {
|
||||
throw new NotInDBError(`User with username '${username}' not found`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the photoUrl of the user or in case no photo url is present generate a deterministic user photo
|
||||
* @param {User} user - the specified User
|
||||
* @return the url of the photo
|
||||
*/
|
||||
getPhotoUrl(user: User): string {
|
||||
if (user.photo) {
|
||||
return user.photo;
|
||||
} else {
|
||||
// TODO: Create new photo, see old code
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build UserInfoDto from a user.
|
||||
* @param {User=} user - the user to use
|
||||
* @return {(UserInfoDto)} the built UserInfoDto
|
||||
*/
|
||||
toUserDto(user: User): UserInfoDto {
|
||||
return {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
photo: this.getPhotoUrl(user),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build FullUserInfoDto from a user.
|
||||
* @param {User=} user - the user to use
|
||||
* @return {(UserInfoDto)} the built FullUserInfoDto
|
||||
*/
|
||||
toFullUserDto(user: User): FullUserInfoDto {
|
||||
return {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
photo: this.getPhotoUrl(user),
|
||||
email: user.email ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
toUserLoginInfoDto(user: User, authProvider: string): UserLoginInfoDto {
|
||||
return { ...this.toUserDto(user), authProvider };
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue