Добавлена возможность заменять файл с расписанием.

Добалена возможность давать доступ к end-point'ам только определённым ролям.

Чуть-чуть меньше спагетти в объявлениях модулей.
This commit is contained in:
2024-10-03 01:49:23 +04:00
parent d18a6764c9
commit 32e06350ad
20 changed files with 361 additions and 46 deletions

22
package-lock.json generated
View File

@@ -34,11 +34,11 @@
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/axios": "^0.14.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/jsdom": "^21.1.7",
"@types/multer": "^1.4.12",
"@types/node": "^20.16.5",
"@types/supertest": "^6.0.0",
"@types/uuid": "^10.0.0",
@@ -2078,16 +2078,6 @@
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true
},
"node_modules/@types/axios": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.0.tgz",
"integrity": "sha512-KqQnQbdYE54D7oa/UmYVMZKq7CO4l8DEENzOKc4aBRwxCXSlJXGz83flFx5L7AWrOQnmuN3kVsRdt+GZPPjiVQ==",
"deprecated": "This is a stub types definition for axios (https://github.com/mzabriskie/axios). axios provides its own type definitions, so you don't need @types/axios installed!",
"dev": true,
"dependencies": {
"axios": "*"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2279,6 +2269,16 @@
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true
},
"node_modules/@types/multer": {
"version": "1.4.12",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz",
"integrity": "sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "20.16.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz",

View File

@@ -1,8 +1,8 @@
{
"name": "schedule-parser-next",
"version": "1.1.1",
"version": "1.2.0",
"description": "",
"author": "",
"author": "N08I40K",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -45,11 +45,11 @@
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/axios": "^0.14.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/jsdom": "^21.1.7",
"@types/multer": "^1.4.12",
"@types/node": "^20.16.5",
"@types/supertest": "^6.0.0",
"@types/uuid": "^10.0.0",

View File

@@ -13,6 +13,12 @@ datasource db {
url = env("DATABASE_URL")
}
model ScheduleReplace {
id String @id @default(auto()) @map("_id") @db.ObjectId
etag String @unique
data Bytes
}
enum UserRole {
STUDENT
TEACHER

View File

@@ -3,6 +3,7 @@ import { AuthModule } from "./auth/auth.module";
import { UsersModule } from "./users/users.module";
import { ScheduleModule } from "./schedule/schedule.module";
import { CacheModule } from "@nestjs/cache-manager";
import { ScheduleReplacerModule } from "./schedule-replacer/schedule-replacer.module";
@Module({
imports: [
@@ -10,6 +11,7 @@ import { CacheModule } from "@nestjs/cache-manager";
UsersModule,
ScheduleModule,
CacheModule.register({ ttl: 5 * 60 * 1000, isGlobal: true }),
ScheduleReplacerModule,
],
controllers: [],
providers: [],

View File

@@ -0,0 +1,4 @@
import { Reflector } from "@nestjs/core";
import { UserRoleDto } from "../dto/user.dto";
export const AuthRoles = Reflector.createDecorator<UserRoleDto[]>();

View File

@@ -73,7 +73,7 @@ export class AuthController {
async signUp(@Body() signUpDto: SignUpReqDto) {
if (
!(await this.scheduleService.getGroupNames()).names.includes(
signUpDto.group,
signUpDto.group.replaceAll(" ", ""),
)
) {
throw new NotFoundException(

View File

@@ -1,18 +1,23 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import { UsersService } from "../users/users.service";
import { Reflector } from "@nestjs/core";
import { AuthRoles } from "../auth-role/auth-role.decorator";
import { isJWT } from "class-validator";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
private readonly reflector: Reflector,
) {}
public static extractTokenFromRequest(req: Request): string {
@@ -28,18 +33,27 @@ export class AuthGuard implements CanActivate {
const request = context.switchToHttp().getRequest();
const token = AuthGuard.extractTokenFromRequest(request);
if (!token)
try {
let jwtUser: { id: string } | null = null;
if (
!(await this.jwtService.verifyAsync(token)) ||
!(await this.usersService.contains({ accessToken: token }))
) {
// noinspection ExceptionCaughtLocallyJS
throw new Error();
}
} catch {
throw new UnauthorizedException("Указан неверный токен!");
}
!isJWT(token) ||
!(jwtUser = await this.jwtService
.verifyAsync(token)
.catch(() => null))
)
throw new UnauthorizedException();
const user = await this.usersService.findUnique({ id: jwtUser.id });
if (!user || user.accessToken !== token)
throw new UnauthorizedException();
const acceptableRoles = this.reflector.get(
AuthRoles,
context.getHandler(),
);
if (acceptableRoles != null && !acceptableRoles.includes(user.role))
throw new ForbiddenException();
return true;
}

View File

@@ -4,20 +4,20 @@ import { jwtConstants } from "../contants";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { UsersModule } from "../users/users.module";
import { UsersService } from "../users/users.service";
import { PrismaService } from "../prisma/prisma.service";
import { ScheduleService } from "../schedule/schedule.service";
import { ScheduleModule } from "../schedule/schedule.module";
@Module({
imports: [
UsersModule,
ScheduleModule,
JwtModule.register({
global: true,
secret: jwtConstants.secret,
signOptions: { expiresIn: "720h" },
}),
],
providers: [AuthService, UsersService, PrismaService, ScheduleService],
providers: [AuthService, PrismaService],
controllers: [AuthController],
exports: [AuthService],
})

View File

@@ -55,15 +55,16 @@ export class AuthService {
}
async signUp(signUpDto: SignUpReqDto): Promise<SignUpResDto> {
const group = signUpDto.group.replaceAll(" ", "");
const username = signUpDto.username.replaceAll(" ", "");
if (
![UserRoleDto.STUDENT, UserRoleDto.TEACHER].includes(signUpDto.role)
) {
throw new NotAcceptableException("Передана неизвестная роль");
}
if (
await this.usersService.contains({ username: signUpDto.username })
) {
if (await this.usersService.contains({ username: username })) {
throw new ConflictException(
"Пользователь с таким именем уже существует!",
);
@@ -74,14 +75,14 @@ export class AuthService {
const input: Prisma.UserCreateInput = {
id: id,
username: signUpDto.username,
username: username,
salt: salt,
password: await hash(signUpDto.password, salt),
accessToken: await this.jwtService.signAsync({
id: id,
}),
role: signUpDto.role as UserRole,
group: signUpDto.group,
group: group,
};
return this.usersService.create(input).then((user) => {
@@ -94,7 +95,7 @@ export class AuthService {
async signIn(signInDto: SignInReqDto): Promise<SignInResDto> {
const user = await this.usersService.findUnique({
username: signInDto.username,
username: signInDto.username.replaceAll(" ", ""),
});
if (

View File

@@ -0,0 +1,29 @@
import { ApiProperty, PickType } from "@nestjs/swagger";
import { IsNumber, IsObject, IsString } from "class-validator";
export class ScheduleReplacerDto {
@ApiProperty({ description: "Etag заменяемого расписания" })
@IsString()
etag: string;
@ApiProperty({ description: "Данные файла расписания" })
@IsObject()
data: ArrayBuffer;
}
export class ScheduleReplacerResDto extends PickType(ScheduleReplacerDto, [
"etag",
]) {
@ApiProperty({ example: 1405, description: "Размер файла в байтах" })
@IsNumber()
size: number;
}
export class ClearScheduleReplacerResDto {
@ApiProperty({
example: 1,
description: "Количество удалённых заменителей расписания",
})
@IsNumber()
count: number;
}

View File

@@ -0,0 +1,20 @@
import { Test, TestingModule } from "@nestjs/testing";
import { ScheduleReplacerController } from "./schedule-replacer.controller";
describe("ScheduleReplacerController", () => {
let controller: ScheduleReplacerController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ScheduleReplacerController],
}).compile();
controller = module.get<ScheduleReplacerController>(
ScheduleReplacerController,
);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,106 @@
import {
BadRequestException,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { AuthGuard } from "src/auth/auth.guard";
import {
ClearScheduleReplacerResDto,
ScheduleReplacerResDto,
} from "../dto/schedule-replacer.dto";
import { AuthRoles } from "../auth-role/auth-role.decorator";
import { UserRoleDto } from "../dto/user.dto";
import { ScheduleReplacerService } from "./schedule-replacer.service";
import { ScheduleService } from "../schedule/schedule.service";
import { FileInterceptor } from "@nestjs/platform-express";
import {
ApiExtraModels,
ApiOkResponse,
ApiOperation,
refs,
} from "@nestjs/swagger";
import { ResultDto } from "src/utility/validation/class-validator.interceptor";
@Controller("/api/v1/schedule-replacer")
@UseGuards(AuthGuard)
export class ScheduleReplacerController {
constructor(
private readonly scheduleService: ScheduleService,
private readonly scheduleReplaceService: ScheduleReplacerService,
) {}
@ApiOperation({
description: "Замена текущего расписание на новое",
tags: ["schedule", "replacer"],
})
@ApiOkResponse({ description: "Замена прошла успешно" })
@Post("set")
@HttpCode(HttpStatus.OK)
@AuthRoles([UserRoleDto.ADMIN])
@ResultDto(null)
@UseInterceptors(
FileInterceptor("file", { limits: { fileSize: 1024 * 1024 } }),
)
async setSchedule(
@UploadedFile() file: Express.Multer.File,
): Promise<void> {
if (!file) throw new BadRequestException("Файл отсутствует");
if (file.mimetype !== "application/vnd.ms-excel")
throw new BadRequestException("Некорректный тип файла");
const etag = (await this.scheduleService.getSourceSchedule()).etag;
await this.scheduleReplaceService.setByEtag(etag, file.buffer);
await this.scheduleService.refreshCache();
}
@ApiExtraModels(ScheduleReplacerResDto)
@ApiOperation({
description: "Получение списка заменителей расписания",
tags: ["schedule", "replacer"],
})
@ApiOkResponse({ description: "Список получен успешно" }) // TODO: ааа((((
@Get("get")
@HttpCode(HttpStatus.OK)
@AuthRoles([UserRoleDto.ADMIN])
@ResultDto(null) // TODO: Как нибудь сделать проверку в таких случаях
async getReplacers(): Promise<ScheduleReplacerResDto[]> {
const etag = (await this.scheduleService.getSourceSchedule()).etag;
const replacer = await this.scheduleReplaceService.getByEtag(etag);
if (!replacer) return [];
return [
{
etag: replacer.etag,
size: replacer.data.byteLength,
},
];
}
@ApiExtraModels(ClearScheduleReplacerResDto)
@ApiOperation({
description: "Удаление всех замен расписаний",
tags: ["schedule", "replacer"],
})
@ApiOkResponse({
description: "Отчистка прошла успешно",
schema: refs(ClearScheduleReplacerResDto)[0],
})
@Post("clear")
@HttpCode(HttpStatus.OK)
@AuthRoles([UserRoleDto.ADMIN])
@ResultDto(ClearScheduleReplacerResDto)
async clear(): Promise<ClearScheduleReplacerResDto> {
const resDto = { count: await this.scheduleReplaceService.clear() };
await this.scheduleService.refreshCache();
return resDto;
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { ScheduleReplacerService } from "./schedule-replacer.service";
import { PrismaService } from "../prisma/prisma.service";
import { ScheduleReplacerController } from "./schedule-replacer.controller";
import { ScheduleModule } from "../schedule/schedule.module";
import { AuthService } from "../auth/auth.service";
import { UsersModule } from "../users/users.module";
@Module({
imports: [ScheduleModule, UsersModule],
providers: [AuthService, PrismaService, ScheduleReplacerService],
exports: [ScheduleReplacerService],
controllers: [ScheduleReplacerController],
})
export class ScheduleReplacerModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from "@nestjs/testing";
import { ScheduleReplacerService } from "./schedule-replacer.service";
describe("ScheduleReplacerService", () => {
let service: ScheduleReplacerService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ScheduleReplacerService],
}).compile();
service = module.get<ScheduleReplacerService>(ScheduleReplacerService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,48 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { ScheduleReplacerDto } from "../dto/schedule-replacer.dto";
import { plainToClass } from "class-transformer";
@Injectable()
export class ScheduleReplacerService {
constructor(private readonly prismaService: PrismaService) {}
async getByEtag(etag: string): Promise<ScheduleReplacerDto | null> {
const response = await this.prismaService.scheduleReplace.findUnique({
where: { etag: etag },
});
if (response == null) return null;
return plainToClass(ScheduleReplacerDto, response);
}
async clear(): Promise<number> {
const count = await this.prismaService.scheduleReplace.count();
await this.prismaService.scheduleReplace.deleteMany({});
return count;
}
async setByEtag(etag: string, buffer: Buffer): Promise<void> {
if (
(await this.prismaService.scheduleReplace.count({
where: { etag: etag },
})) > 0
) {
await this.prismaService.scheduleReplace.update({
where: { etag: etag },
data: {
data: buffer,
},
});
return;
}
await this.prismaService.scheduleReplace.create({
data: {
etag: etag,
data: buffer,
},
});
}
}

View File

@@ -290,6 +290,10 @@ export class ScheduleParser {
const llesson = lday.lessons[lessonIdx];
// noinspection SpellCheckingInspection
const rlesson = rday.lessons[lessonIdx];
if (llesson === null && rlesson === null) continue;
if (!llesson || !rlesson) return false;
if (
llesson.name.length > 0 &&
(llesson.name !== rlesson.name ||

View File

@@ -9,11 +9,22 @@ import {
NotAcceptableException,
ServiceUnavailableException,
} from "@nestjs/common";
import { ScheduleReplacerService } from "../../../schedule-replacer/schedule-replacer.service";
import { Error } from "mongoose";
import * as crypto from "crypto";
export class BasicXlsDownloader extends XlsDownloaderBase {
cache: XlsDownloaderResult | null = null;
preparedData: { downloadLink: string; updateDate: string } | null = null;
private cacheHash: string = "0000000000000000000000000000000000000000";
private lastUpdate: number = 0;
private scheduleReplacerService: ScheduleReplacerService | null = null;
setScheduleReplacerService(service: ScheduleReplacerService) {
this.scheduleReplacerService = service;
}
private async getDOM(preparedData: any): Promise<JSDOM | null> {
try {
@@ -103,17 +114,32 @@ export class BasicXlsDownloader extends XlsDownloaderBase {
${response.statusText}`);
}
const replacer = await this.scheduleReplacerService.getByEtag(
response.headers["etag"]!,
);
const fileData: ArrayBuffer = replacer
? replacer.data
: response.data.buffer;
const fileDataHash = crypto
.createHash("sha1")
.update(Buffer.from(fileData).toString("base64"))
.digest("hex");
const result: XlsDownloaderResult = {
fileData: response.data.buffer,
fileData: fileData,
updateDate: this.preparedData.updateDate,
etag: response.headers["etag"],
new:
this.cacheMode === XlsDownloaderCacheMode.NONE
? true
: this.cache?.etag !== response.headers["etag"],
: this.cacheHash !== fileDataHash,
updateRequired: this.isUpdateRequired(),
};
this.cacheHash = fileDataHash;
if (this.cacheMode !== XlsDownloaderCacheMode.NONE) this.cache = result;
return result;

View File

@@ -3,9 +3,15 @@ import { ScheduleService } from "./schedule.service";
import { ScheduleController } from "./schedule.controller";
import { UsersService } from "../users/users.service";
import { PrismaService } from "../prisma/prisma.service";
import { ScheduleReplacerService } from "../schedule-replacer/schedule-replacer.service";
@Module({
providers: [ScheduleService, UsersService, PrismaService],
providers: [
ScheduleService,
ScheduleReplacerService,
UsersService,
PrismaService,
],
controllers: [ScheduleController],
exports: [ScheduleService],
})

View File

@@ -17,6 +17,7 @@ import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
import { instanceToPlain } from "class-transformer";
import { cacheGetOrFill } from "../utility/cache.util";
import * as crypto from "crypto";
import { ScheduleReplacerService } from "../schedule-replacer/schedule-replacer.service";
@Injectable()
export class ScheduleService {
@@ -33,7 +34,18 @@ export class ScheduleService {
private lastChangedDays: Array<Array<number>> = [];
private scheduleUpdatedAt: Date = new Date(0);
constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {}
constructor(
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
private readonly scheduleReplacerService: ScheduleReplacerService,
) {
const xlsDownloader = this.scheduleParser.getXlsDownloader();
if (xlsDownloader instanceof BasicXlsDownloader) {
xlsDownloader.setScheduleReplacerService(
this.scheduleReplacerService,
);
}
}
getCacheStatus(): CacheStatusDto {
return {
@@ -45,7 +57,7 @@ export class ScheduleService {
};
}
private async getSourceSchedule(): Promise<ScheduleParseResult> {
async getSourceSchedule(): Promise<ScheduleParseResult> {
return cacheGetOrFill(this.cacheManager, "sourceSchedule", async () => {
const schedule = await this.scheduleParser.getSchedule();
schedule.groups = ScheduleService.toObject(
@@ -146,10 +158,13 @@ export class ScheduleService {
await this.scheduleParser
.getXlsDownloader()
.setPreparedData(siteMainPageDto.mainPage);
await this.cacheManager.reset();
await this.getSourceSchedule();
await this.refreshCache();
return this.getCacheStatus();
}
async refreshCache() {
await this.cacheManager.reset();
await this.getSourceSchedule();
}
}

View File

@@ -3,10 +3,11 @@ import { UsersService } from "./users.service";
import { PrismaService } from "../prisma/prisma.service";
import { UsersController } from "./users.controller";
import { AuthService } from "../auth/auth.service";
import { ScheduleService } from "../schedule/schedule.service";
import { ScheduleModule } from "../schedule/schedule.module";
@Module({
providers: [PrismaService, UsersService, AuthService, ScheduleService],
imports: [ScheduleModule],
providers: [PrismaService, UsersService, AuthService],
exports: [UsersService],
controllers: [UsersController],
})