base authentication
This commit is contained in:
parent
21f7aa3187
commit
d9e7ae06fb
18
dev/docker-compose.yml
Normal file
18
dev/docker-compose.yml
Normal file
|
@ -0,0 +1,18 @@
|
|||
version: "3"
|
||||
services:
|
||||
devdb:
|
||||
image: postgres:11-alpine
|
||||
environment:
|
||||
POSTGRES_DB: imagur
|
||||
POSTGRES_PASSWORD: imagur
|
||||
POSTGRES_USER: imagur
|
||||
logging:
|
||||
driver: "none"
|
||||
ports:
|
||||
- "5432:5432"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
db-data:
|
22
package.json
22
package.json
|
@ -13,23 +13,39 @@
|
|||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"devdb:start": "podman-compose -f ./dev/docker-compose.yml up -d",
|
||||
"devdb:stop": "podman-compose -f ./dev/docker-compose.yml down",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^8.1.1",
|
||||
"@nestjs/core": "^8.1.1",
|
||||
"@nestjs/platform-express": "^8.1.1",
|
||||
"@nestjs/jwt": "^8.0.0",
|
||||
"@nestjs/passport": "^8.2.1",
|
||||
"@nestjs/platform-fastify": "^8.3.1",
|
||||
"@nestjs/typeorm": "^8.0.3",
|
||||
"bcrypt": "^5.0.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"passport": "^0.5.2",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.7.3",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.4.0"
|
||||
"rxjs": "^7.4.0",
|
||||
"typeorm": "^0.2.43"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^8.1.3",
|
||||
"@nestjs/schematics": "^8.0.4",
|
||||
"@nestjs/testing": "^8.1.1",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/node": "^16.11.1",
|
||||
"@types/passport-jwt": "^3.0.6",
|
||||
"@types/passport-local": "^1.0.34",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.2",
|
||||
"@typescript-eslint/parser": "^4.29.2",
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
|
@ -1,10 +1,27 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UserEntity } from './users/user.entity';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import Config from './env';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
imports: [
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
host: Config.database.host,
|
||||
port: Config.database.port,
|
||||
username: Config.database.username,
|
||||
password: Config.database.password,
|
||||
database: Config.database.database,
|
||||
|
||||
autoLoadEntities: true,
|
||||
synchronize: true,
|
||||
|
||||
entities: [UserEntity],
|
||||
}),
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
27
src/auth/admin.guard.ts
Normal file
27
src/auth/admin.guard.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { User } from 'src/users/user.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
private readonly logger = new Logger('AdminGuard');
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const user = plainToClass(User, request.user);
|
||||
const errors = await validate(user);
|
||||
if (errors.length > 0) {
|
||||
this.logger.warn(`Invalid user payload: ${JSON.stringify(request.user)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return user.isAdmin;
|
||||
}
|
||||
}
|
71
src/auth/auth.controller.ts
Normal file
71
src/auth/auth.controller.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
UseGuards,
|
||||
Request,
|
||||
Body,
|
||||
Get,
|
||||
} from '@nestjs/common';
|
||||
import { LocalAuthGuard } from './local-auth.guard';
|
||||
import {
|
||||
RegisterRequestDto,
|
||||
LoginResponseDto,
|
||||
DeleteRequestDto,
|
||||
} from './auth.dto';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt.guard';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { Nothing, AsyncMaybe, Maybe } from 'src/lib/maybe';
|
||||
import { User } from 'src/users/user.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@UseGuards(LocalAuthGuard)
|
||||
@Post('login')
|
||||
async login(@Request() req) {
|
||||
const response: LoginResponseDto = {
|
||||
access_token: await this.authService.createToken(req.user),
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Post('create')
|
||||
async register(@Request() req, @Body() register: RegisterRequestDto) {
|
||||
const user = await this.authService.createUser(
|
||||
register.username,
|
||||
register.password,
|
||||
);
|
||||
|
||||
if (user === Nothing) {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
if (register.isAdmin) {
|
||||
await this.authService.makeAdmin(user);
|
||||
}
|
||||
|
||||
return this.authService.userEntityToUser(user);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Post('delete')
|
||||
async delete(@Request() req, @Body() deleteData: DeleteRequestDto) {
|
||||
const user = await this.authService.deleteUser(deleteData.username);
|
||||
|
||||
if (user === Nothing) {
|
||||
throw new Error('User does not exist');
|
||||
}
|
||||
|
||||
return this.authService.userEntityToUser(user);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@Get('list')
|
||||
async listUsers(@Request() req) {
|
||||
return this.authService.listUsers();
|
||||
}
|
||||
}
|
38
src/auth/auth.dto.ts
Normal file
38
src/auth/auth.dto.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { User } from 'src/users/user.dto';
|
||||
|
||||
export class LoginResponseDto {
|
||||
@IsString()
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
export class RegisterRequestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export class DeleteRequestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username: string;
|
||||
}
|
||||
|
||||
export class JwtDataDto {
|
||||
@ValidateNested()
|
||||
user: User;
|
||||
}
|
49
src/auth/auth.module.ts
Normal file
49
src/auth/auth.module.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { UsersModule } from 'src/users/users.module';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LocalStrategy } from './local.strategy';
|
||||
import { AuthController } from './auth.controller';
|
||||
import Config from 'src/env';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: Config.jwt.secret,
|
||||
signOptions: { expiresIn: Config.jwt.expiresIn },
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, LocalStrategy, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule implements OnModuleInit {
|
||||
private readonly logger = new Logger('AuthModule');
|
||||
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.checkJwtSecret();
|
||||
|
||||
this.ensureAdminExists();
|
||||
}
|
||||
|
||||
private checkJwtSecret() {
|
||||
if (Config.jwt.secret === 'CHANGE_ME') {
|
||||
this.logger.error(
|
||||
"JWT secret is not set. Please set the 'JWT_SECRET' environment variable.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAdminExists() {
|
||||
const admin = Config.defaultAdmin;
|
||||
this.logger.debug(`Ensuring admin user ${admin.username} exists`);
|
||||
|
||||
await this.authService.createUser(admin.username, admin.password);
|
||||
await this.authService.makeAdmin(admin.username);
|
||||
}
|
||||
}
|
69
src/auth/auth.service.ts
Normal file
69
src/auth/auth.service.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AsyncMaybe, Nothing } from 'src/lib/maybe';
|
||||
import { User } from 'src/users/user.dto';
|
||||
import { UserEntity } from 'src/users/user.entity';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
import { JwtDataDto } from './auth.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async createUser(username: string, password: string): AsyncMaybe<UserEntity> {
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
return this.usersService.createUser(username, hashedPassword);
|
||||
}
|
||||
|
||||
async deleteUser(user: string | UserEntity): AsyncMaybe<UserEntity> {
|
||||
return this.usersService.removeUser(user);
|
||||
}
|
||||
|
||||
async listUsers(): Promise<User[]> {
|
||||
const users = await this.usersService.findAll();
|
||||
return users.map((user) => this.userEntityToUser(user));
|
||||
}
|
||||
|
||||
async authenticate(
|
||||
username: string,
|
||||
password: string,
|
||||
): AsyncMaybe<UserEntity> {
|
||||
const user = await this.usersService.findOne(username);
|
||||
|
||||
if (user === Nothing) return Nothing;
|
||||
|
||||
if (!(await bcrypt.compare(password, user.password))) return Nothing;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async createToken(user: UserEntity): Promise<string> {
|
||||
const jwtData: JwtDataDto = {
|
||||
user: {
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin,
|
||||
},
|
||||
};
|
||||
|
||||
return this.jwtService.signAsync(jwtData);
|
||||
}
|
||||
|
||||
async makeAdmin(user: string | UserEntity): Promise<boolean> {
|
||||
return this.usersService.modifyAdmin(user, true);
|
||||
}
|
||||
|
||||
async revokeAdmin(user: string | UserEntity): Promise<boolean> {
|
||||
return this.usersService.modifyAdmin(user, false);
|
||||
}
|
||||
|
||||
userEntityToUser(user: UserEntity): User {
|
||||
return {
|
||||
username: user.username,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
}
|
||||
}
|
5
src/auth/jwt.guard.ts
Normal file
5
src/auth/jwt.guard.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
33
src/auth/jwt.strategy.ts
Normal file
33
src/auth/jwt.strategy.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import Config from 'src/env';
|
||||
import { validate } from 'class-validator';
|
||||
import { JwtDataDto } from './auth.dto';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { User } from 'src/users/user.dto';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
private readonly logger = new Logger('JwtStrategy');
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: Config.jwt.secret,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any): Promise<User> {
|
||||
const jwt = plainToClass(JwtDataDto, payload);
|
||||
|
||||
const errors = await validate(jwt);
|
||||
if (errors.length > 0) {
|
||||
this.logger.warn(`Invalid JWT payload: ${JSON.stringify(payload)}`);
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return jwt.user;
|
||||
}
|
||||
}
|
5
src/auth/local-auth.guard.ts
Normal file
5
src/auth/local-auth.guard.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard('local') {}
|
21
src/auth/local.strategy.ts
Normal file
21
src/auth/local.strategy.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { Strategy } from 'passport-local';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AsyncMaybe, Nothing } from 'src/lib/maybe';
|
||||
import { UserEntity } from 'src/users/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(username: string, password: string): AsyncMaybe<UserEntity> {
|
||||
const user = await this.authService.authenticate(username, password);
|
||||
if (user === Nothing) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
19
src/env.ts
Normal file
19
src/env.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
const Config = {
|
||||
database: {
|
||||
host: process.env.DB_HOST ?? 'localhost',
|
||||
port: parseInt(process.env.DB_PORT) ?? 5432,
|
||||
username: process.env.DB_USERNAME ?? 'imagur',
|
||||
password: process.env.DB_PASSWORD ?? 'imagur',
|
||||
database: process.env.DB_DATABASE ?? 'imagur',
|
||||
},
|
||||
defaultAdmin: {
|
||||
username: process.env.DEFAULT_ADMIN_USERNAME ?? 'admin',
|
||||
password: process.env.DEFAULT_ADMIN_PASSWORD ?? 'admin',
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET ?? 'CHANGE_ME',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN ?? '1d',
|
||||
},
|
||||
};
|
||||
|
||||
export default Config;
|
5
src/lib/maybe.ts
Normal file
5
src/lib/maybe.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export const Nothing = undefined;
|
||||
export type Nothing = undefined;
|
||||
export type Maybe<T> = T | Nothing;
|
||||
|
||||
export type AsyncMaybe<T> = Promise<Maybe<T>>;
|
11
src/main.ts
11
src/main.ts
|
@ -1,8 +1,17 @@
|
|||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import {
|
||||
FastifyAdapter,
|
||||
NestFastifyApplication,
|
||||
} from '@nestjs/platform-fastify';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter(),
|
||||
);
|
||||
app.useGlobalPipes(new ValidationPipe({ disableErrorMessages: true }));
|
||||
await app.listen(3000);
|
||||
}
|
||||
|
||||
|
|
10
src/users/user.dto.ts
Normal file
10
src/users/user.dto.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class User {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username: string;
|
||||
|
||||
@IsBoolean()
|
||||
isAdmin: boolean;
|
||||
}
|
16
src/users/user.entity.ts
Normal file
16
src/users/user.entity.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export class UserEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
username: string;
|
||||
|
||||
@Column()
|
||||
password: string;
|
||||
|
||||
@Column({ default: false })
|
||||
isAdmin: boolean;
|
||||
}
|
11
src/users/users.module.ts
Normal file
11
src/users/users.module.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserEntity } from './user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity])],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
71
src/users/users.service.ts
Normal file
71
src/users/users.service.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { AsyncMaybe, Nothing } from 'src/lib/maybe';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity)
|
||||
private usersRepository: Repository<UserEntity>,
|
||||
) {}
|
||||
|
||||
async createUser(
|
||||
username: string,
|
||||
hashedPassword: string,
|
||||
): AsyncMaybe<UserEntity> {
|
||||
if (await this.exists(username)) return Nothing;
|
||||
|
||||
const user = new UserEntity();
|
||||
user.username = username;
|
||||
user.password = hashedPassword;
|
||||
await this.usersRepository.save(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async removeUser(user: string | UserEntity): AsyncMaybe<UserEntity> {
|
||||
const userToModify = await this.resolveUser(user);
|
||||
|
||||
if (user === Nothing) return Nothing;
|
||||
|
||||
await this.usersRepository.remove(userToModify);
|
||||
|
||||
return userToModify;
|
||||
}
|
||||
|
||||
async findOne(username: string): AsyncMaybe<UserEntity> {
|
||||
return await this.usersRepository.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
async findAll(): Promise<UserEntity[]> {
|
||||
return await this.usersRepository.find();
|
||||
}
|
||||
|
||||
async exists(username: string): Promise<boolean> {
|
||||
return (await this.findOne(username)) !== Nothing;
|
||||
}
|
||||
|
||||
async modifyAdmin(
|
||||
user: string | UserEntity,
|
||||
admin: boolean,
|
||||
): Promise<boolean> {
|
||||
const userToModify = await this.resolveUser(user);
|
||||
|
||||
if (userToModify === Nothing) return false;
|
||||
|
||||
userToModify.isAdmin = admin;
|
||||
await this.usersRepository.save(userToModify);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async resolveUser(user: string | UserEntity): Promise<UserEntity> {
|
||||
if (typeof user === 'string') {
|
||||
return await this.findOne(user);
|
||||
} else {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue