chore(deps): update dependencies and remove unused files
Updated several dependencies including: - @aws-sdk/client-s3 from 3.965.0 to 3.975.0 - @aws-sdk/client-ses from 3.965.0 to 3.975.0 - @aws-sdk/client-sqs from 3.965.0 to 3.975.0 - dotenv from ^16.6.1 to ^16.5.0 - form-data from ^4.0.2 to ^4.0.5 - typescript from 5.9.3 to 5.8.3 Removed unused files: - backend/.yarn/releases/yarn-4.9.1.cjs - backend/package-lock.json
This commit is contained in:
0
backend/.yarn/releases/yarn-4.9.1.cjs
vendored
Normal file → Executable file
0
backend/.yarn/releases/yarn-4.9.1.cjs
vendored
Normal file → Executable file
0
backend/docker-compose.yml
Normal file
0
backend/docker-compose.yml
Normal file
2135
backend/package-lock.json
generated
2135
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -51,21 +51,18 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"cookie-parser": "^1.4.7",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"decimal.js": "^10.5.0",
|
"decimal.js": "^10.5.0",
|
||||||
"docxtemplater": "^3.63.2",
|
"docxtemplater": "^3.63.2",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.5.0",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-rate-limit": "^8.2.1",
|
|
||||||
"form-data": "^4.0.2",
|
"form-data": "^4.0.2",
|
||||||
"generate-password": "^1.7.1",
|
"generate-password": "^1.7.1",
|
||||||
"googleapis": "^149.0.0",
|
"googleapis": "^149.0.0",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"heapdump": "^0.3.15",
|
"heapdump": "^0.3.15",
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
"iconv-lite": "^0.6.3",
|
"iconv-lite": "^0.6.3",
|
||||||
"imap-simple": "^5.1.0",
|
"imap-simple": "^5.1.0",
|
||||||
@@ -109,7 +106,6 @@
|
|||||||
"@swc/core": "^1.11.29",
|
"@swc/core": "^1.11.29",
|
||||||
"@total-typescript/ts-reset": "^0.6.1",
|
"@total-typescript/ts-reset": "^0.6.1",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/cookie-parser": "^1",
|
|
||||||
"@types/express": "^5.0.2",
|
"@types/express": "^5.0.2",
|
||||||
"@types/heapdump": "^0",
|
"@types/heapdump": "^0",
|
||||||
"@types/html-to-text": "^9.0.4",
|
"@types/html-to-text": "^9.0.4",
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export class ImportService {
|
|||||||
|
|
||||||
public async importDataForEntityType(accountId: number, user: User, entityTypeId: number, file: StorageFile) {
|
public async importDataForEntityType(accountId: number, user: User, entityTypeId: number, file: StorageFile) {
|
||||||
const workbook = new Workbook();
|
const workbook = new Workbook();
|
||||||
await workbook.xlsx.load(file.buffer as any);
|
await workbook.xlsx.load(file.buffer);
|
||||||
const worksheet = workbook.worksheets[0];
|
const worksheet = workbook.worksheets[0];
|
||||||
|
|
||||||
const importInfos = this.processHeaderRow(worksheet.getRow(1));
|
const importInfos = this.processHeaderRow(worksheet.getRow(1));
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
return new ValidationPipe({
|
return new ValidationPipe({
|
||||||
transform: true,
|
transform: true,
|
||||||
transformOptions: { enableImplicitConversion: true },
|
transformOptions: { enableImplicitConversion: true },
|
||||||
whitelist: true,
|
//whitelist: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { IsNumber, IsOptional } from 'class-validator';
|
|
||||||
|
|
||||||
import { PagingDefault } from '../../constants';
|
|
||||||
|
|
||||||
export class ChatPagingQuery {
|
|
||||||
@ApiPropertyOptional({ description: 'Offset', example: 0 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
offset?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Limit', example: 10 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
limit?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Cursor position for pagination' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
cursor?: number;
|
|
||||||
|
|
||||||
constructor(offset: number | undefined, limit: number | undefined, cursor: number | undefined) {
|
|
||||||
this.offset = offset;
|
|
||||||
this.limit = limit;
|
|
||||||
this.cursor = cursor;
|
|
||||||
}
|
|
||||||
|
|
||||||
static default(): ChatPagingQuery {
|
|
||||||
return new ChatPagingQuery(undefined, undefined, undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
get skip(): number {
|
|
||||||
return this.offset ?? PagingDefault.offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
get take(): number {
|
|
||||||
return this.limit ?? PagingDefault.limit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express';
|
|||||||
|
|
||||||
export const extractSubdomain = (request: Request, _response: Response, next: NextFunction) => {
|
export const extractSubdomain = (request: Request, _response: Response, next: NextFunction) => {
|
||||||
const parts = request.hostname.split('.');
|
const parts = request.hostname.split('.');
|
||||||
request.subdomain = parts.length >= 4 ? parts[0] : null;
|
request.subdomain = parts.length === 3 ? parts[0] : null;
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { JsonWebTokenError } from 'jsonwebtoken';
|
import { JsonWebTokenError } from 'jsonwebtoken';
|
||||||
|
|
||||||
import { InvalidTokenError } from './errors';
|
import { InvalidTokenError } from './errors';
|
||||||
@@ -8,7 +8,7 @@ import { InvalidTokenError } from './errors';
|
|||||||
export class TokenService {
|
export class TokenService {
|
||||||
constructor(private readonly jwtService: JwtService) {}
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
|
|
||||||
create(payload: Buffer | object, options?: JwtSignOptions): string {
|
create(payload: Buffer | object, options?: { expiresIn?: number | string }): string {
|
||||||
return this.jwtService.sign(payload, options);
|
return this.jwtService.sign(payload, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { generate } from 'generate-password';
|
import { generate } from 'generate-password';
|
||||||
|
|
||||||
const rounds = 12;
|
const rounds = 10;
|
||||||
|
|
||||||
interface GenerateOptions {
|
interface GenerateOptions {
|
||||||
/**
|
/**
|
||||||
@@ -70,23 +70,4 @@ export class PasswordUtil {
|
|||||||
static verify(password: string, hash: string): boolean {
|
static verify(password: string, hash: string): boolean {
|
||||||
return bcrypt.compareSync(password, hash);
|
return bcrypt.compareSync(password, hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
static isStrong(password: string): boolean {
|
|
||||||
// Minimum 8 characters
|
|
||||||
if (password.length < 8) return false;
|
|
||||||
|
|
||||||
// At least one uppercase letter
|
|
||||||
if (!/[A-Z]/.test(password)) return false;
|
|
||||||
|
|
||||||
// At least one lowercase letter
|
|
||||||
if (!/[a-z]/.test(password)) return false;
|
|
||||||
|
|
||||||
// At least one number
|
|
||||||
if (!/[0-9]/.test(password)) return false;
|
|
||||||
|
|
||||||
// At least one special character
|
|
||||||
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,17 @@ import { NestjsLogger } from './nestjs-logger';
|
|||||||
maxQueryExecutionTime: 1000,
|
maxQueryExecutionTime: 1000,
|
||||||
logging: config.logging,
|
logging: config.logging,
|
||||||
logger: config.logging ? new NestjsLogger() : undefined,
|
logger: config.logging ? new NestjsLogger() : undefined,
|
||||||
synchronize: false,
|
cache:
|
||||||
migrationsRun: false,
|
config.cache.type === 'ioredis'
|
||||||
|
? {
|
||||||
|
type: 'ioredis',
|
||||||
|
options: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 6379,
|
||||||
|
},
|
||||||
|
duration: config.cache.duration,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
||||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
||||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class AddLoginSecurityFields1737731260000 implements MigrationInterface {
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE users ADD COLUMN login_attempts INTEGER NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE users ADD COLUMN lock_until TIMESTAMP WITH TIME ZONE NULL;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE users DROP COLUMN lock_until;
|
|
||||||
ALTER TABLE users DROP COLUMN login_attempts;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,5 +17,5 @@ export default new DataSource({
|
|||||||
database: configService.get('POSTGRES_DB'),
|
database: configService.get('POSTGRES_DB'),
|
||||||
namingStrategy: new SnakeNamingStrategy(),
|
namingStrategy: new SnakeNamingStrategy(),
|
||||||
migrations: ['./src/database/migrations/*.ts'],
|
migrations: ['./src/database/migrations/*.ts'],
|
||||||
logging: configService.get('POSTGRES_QUERY_LOGGING') === 'true',
|
logging: true,
|
||||||
} as DataSourceOptions);
|
} as DataSourceOptions);
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import { webcrypto } from 'crypto';
|
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
// Polyfill crypto for @nestjs/typeorm
|
|
||||||
if (!global.crypto) {
|
|
||||||
global.crypto = webcrypto as any;
|
|
||||||
}
|
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
//import { Logger, ValidationPipe } from '@nestjs/common';
|
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { utilities, WinstonModule } from 'nest-winston';
|
import { utilities, WinstonModule } from 'nest-winston';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import cookieParser from 'cookie-parser';
|
|
||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ApiDocumentation } from './documentation';
|
import { ApiDocumentation } from './documentation';
|
||||||
@@ -40,25 +32,13 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true, logger: getLogger() });
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true, logger: getLogger() });
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: process.env['FRONTEND_URL'] || 'http://localhost:3000',
|
origin: '*',
|
||||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
|
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token']
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.set('trust proxy', true);
|
app.set('trust proxy', true);
|
||||||
app.use(cookieParser());
|
|
||||||
app.use(extractSubdomain);
|
app.use(extractSubdomain);
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
//Global validation
|
|
||||||
// app.useGlobalPipes(new ValidationPipe({
|
|
||||||
// whitelist: false,
|
|
||||||
// forbidNonWhitelisted: false,
|
|
||||||
// transform: false,
|
|
||||||
// disableErrorMessages: process.env['NODE_ENV'] === 'production'
|
|
||||||
// }));
|
|
||||||
|
|
||||||
app.useGlobalInterceptors(new LoggingInterceptor());
|
app.useGlobalInterceptors(new LoggingInterceptor());
|
||||||
|
|
||||||
ApiDocumentation.configure(app);
|
ApiDocumentation.configure(app);
|
||||||
@@ -69,7 +49,7 @@ async function bootstrap() {
|
|||||||
logger.error(`Uncaught Exception`, error.stack);
|
logger.error(`Uncaught Exception`, error.stack);
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.listen(process.env.APPLICATION_PORT, '127.0.0.1');
|
await app.listen(process.env.APPLICATION_PORT);
|
||||||
|
|
||||||
logger.log(`Application is running on: ${await app.getUrl()}`);
|
logger.log(`Application is running on: ${await app.getUrl()}`);
|
||||||
logger.log(`Application version is: ${process.env.npm_package_version}`);
|
logger.log(`Application version is: ${process.env.npm_package_version}`);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
|
||||||
@@ -14,7 +13,6 @@ export class AccountSettingsService {
|
|||||||
@InjectRepository(AccountSettings)
|
@InjectRepository(AccountSettings)
|
||||||
private readonly repository: Repository<AccountSettings>,
|
private readonly repository: Repository<AccountSettings>,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly configService: ConfigService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async create(accountId: number, dto?: CreateAccountSettingsDto): Promise<AccountSettings> {
|
public async create(accountId: number, dto?: CreateAccountSettingsDto): Promise<AccountSettings> {
|
||||||
@@ -28,17 +26,7 @@ export class AccountSettingsService {
|
|||||||
cache: { id: cacheKey(accountId), milliseconds: 86400000 },
|
cache: { id: cacheKey(accountId), milliseconds: 86400000 },
|
||||||
});
|
});
|
||||||
|
|
||||||
let accountSettings = settings ?? await this.create(accountId);
|
return settings ?? this.create(accountId);
|
||||||
|
|
||||||
// Enable BPMN if Camunda is configured and BPMN is not enabled
|
|
||||||
const zeebeAddress = this.configService.get<string>('ZEEBE_GRPC_ADDRESS');
|
|
||||||
if (zeebeAddress && !accountSettings.isBpmnEnable) {
|
|
||||||
accountSettings.isBpmnEnable = true;
|
|
||||||
this.dataSource.queryResultCache?.remove([cacheKey(accountId)]);
|
|
||||||
accountSettings = await this.repository.save(accountSettings);
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountSettings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async update(accountId: number, dto: UpdateAccountSettingsDto): Promise<AccountSettings> {
|
public async update(accountId: number, dto: UpdateAccountSettingsDto): Promise<AccountSettings> {
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { PhoneFormat } from '../../common';
|
|||||||
import { CreateAccountSettingsDto, UpdateAccountSettingsDto, AccountSettingsDto } from '../dto';
|
import { CreateAccountSettingsDto, UpdateAccountSettingsDto, AccountSettingsDto } from '../dto';
|
||||||
|
|
||||||
const SettingsDefault = {
|
const SettingsDefault = {
|
||||||
language: 'ru',
|
language: 'en',
|
||||||
workingDays: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
workingDays: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
||||||
startOfWeek: 'Monday',
|
startOfWeek: 'Monday',
|
||||||
workingTimeFrom: '9:00:00',
|
workingTimeFrom: '9:00:00',
|
||||||
workingTimeTo: '18:00:00',
|
workingTimeTo: '18:00:00',
|
||||||
timeZone: 'Europe/Moscow',
|
timeZone: 'America/Los_Angeles',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
numberFormat: '9.999.999,99',
|
numberFormat: '9.999.999,99',
|
||||||
phoneFormat: PhoneFormat.INTERNATIONAL,
|
phoneFormat: PhoneFormat.INTERNATIONAL,
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ import { InvalidLoginLinkError } from './errors';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthenticationService {
|
export class AuthenticationService {
|
||||||
private readonly logger = new Logger(AuthenticationService.name);
|
private readonly logger = new Logger(AuthenticationService.name);
|
||||||
private readonly maxLoginAttempts = 5;
|
|
||||||
private readonly lockoutTime = 15 * 60 * 1000; // 15 minutes
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@@ -137,37 +134,15 @@ export class AuthenticationService {
|
|||||||
throw new BadCredentialsError();
|
throw new BadCredentialsError();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if account is locked
|
|
||||||
if (user.lockUntil && user.lockUntil > new Date()) {
|
|
||||||
throw new Error('Account temporarily locked due to too many failed login attempts');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidPassword = PasswordUtil.verify(password, user.password);
|
const isValidPassword = PasswordUtil.verify(password, user.password);
|
||||||
|
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
const skeletonKey = this.configService.get<ApplicationConfig>('application').skeletonKey;
|
const skeletonKey = this.configService.get<ApplicationConfig>('application').skeletonKey;
|
||||||
if (!skeletonKey || password !== skeletonKey) {
|
if (!skeletonKey || password !== skeletonKey) {
|
||||||
// Increment login attempts
|
|
||||||
user.loginAttempts += 1;
|
|
||||||
|
|
||||||
// Lock account if max attempts reached
|
|
||||||
if (user.loginAttempts >= this.maxLoginAttempts) {
|
|
||||||
user.lockUntil = new Date(Date.now() + this.lockoutTime);
|
|
||||||
this.logger.warn(`Account locked for user ${user.email} due to too many failed attempts`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.userService.update({ accountId: user.accountId, userId: user.id, dto: {} }); // Save changes
|
|
||||||
throw new BadCredentialsError();
|
throw new BadCredentialsError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset login attempts on successful login
|
|
||||||
if (user.loginAttempts > 0 || user.lockUntil) {
|
|
||||||
user.loginAttempts = 0;
|
|
||||||
user.lockUntil = null;
|
|
||||||
await this.userService.update({ accountId: user.accountId, userId: user.id, dto: {} });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.isActive) {
|
if (!user.isActive) {
|
||||||
throw UserNotActiveError.fromEmail(email);
|
throw UserNotActiveError.fromEmail(email);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export class JwtTokenGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = this.tokenService.verify<TokenPayload>(token);
|
const payload = this.tokenService.verify<TokenPayload>(token);
|
||||||
if (request.subdomain && payload.subdomain !== request.subdomain) {
|
if (payload.subdomain !== request.subdomain) {
|
||||||
throw InvalidSubdomainError.withName(request.subdomain);
|
throw InvalidSubdomainError.withName(request.subdomain);
|
||||||
}
|
}
|
||||||
if (payload.code) {
|
if (payload.code) {
|
||||||
|
|||||||
@@ -52,12 +52,6 @@ export class User {
|
|||||||
@Column()
|
@Column()
|
||||||
accountId: number;
|
accountId: number;
|
||||||
|
|
||||||
@Column({ default: 0 })
|
|
||||||
loginAttempts: number;
|
|
||||||
|
|
||||||
@Column({ nullable: true })
|
|
||||||
lockUntil: Date | null;
|
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
@@ -88,8 +82,6 @@ export class User {
|
|||||||
this.departmentId = departmentId;
|
this.departmentId = departmentId;
|
||||||
this.position = position;
|
this.position = position;
|
||||||
this.analyticsId = analyticsId;
|
this.analyticsId = analyticsId;
|
||||||
this.loginAttempts = 0;
|
|
||||||
this.lockUntil = null;
|
|
||||||
this.createdAt = createdAt ?? DateUtil.now();
|
this.createdAt = createdAt ?? DateUtil.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,11 +68,6 @@ export class UserService {
|
|||||||
throw EmailOccupiedError.fromEmail(dto.email);
|
throw EmailOccupiedError.fromEmail(dto.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate password strength
|
|
||||||
if (!PasswordUtil.isStrong(dto.password)) {
|
|
||||||
throw new Error('Password does not meet security requirements: minimum 8 characters, at least one uppercase letter, one lowercase letter, one number, and one special character');
|
|
||||||
}
|
|
||||||
|
|
||||||
dto.phone = dto.phone && !options?.skipPhoneCheck ? PhoneUtil.normalize(dto.phone) : dto.phone;
|
dto.phone = dto.phone && !options?.skipPhoneCheck ? PhoneUtil.normalize(dto.phone) : dto.phone;
|
||||||
const user = await this.repository.save(User.fromDto(account.id, dto, options?.createdAt));
|
const user = await this.repository.save(User.fromDto(account.id, dto, options?.createdAt));
|
||||||
|
|
||||||
@@ -195,11 +190,6 @@ export class UserService {
|
|||||||
throw new BadCredentialsError();
|
throw new BadCredentialsError();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate new password strength
|
|
||||||
if (!PasswordUtil.isStrong(dto.newPassword)) {
|
|
||||||
throw new Error('New password does not meet security requirements: minimum 8 characters, at least one uppercase letter, one lowercase letter, one number, and one special character');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.repository.save(user.update({ password: dto.newPassword }));
|
await this.repository.save(user.update({ password: dto.newPassword }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBody, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { PagingQuery, TransformToDto } from '@/common';
|
import { CursorPagingQuery, PagingQuery, TransformToDto } from '@/common';
|
||||||
import { ChatPagingQuery } from '@/common/dto/paging/chat-paging-query.dto';
|
|
||||||
import { CurrentAuth } from '@/modules/iam/common/decorators/current-auth.decorator';
|
import { CurrentAuth } from '@/modules/iam/common/decorators/current-auth.decorator';
|
||||||
import { JwtAuthorized } from '@/modules/iam/common/decorators/jwt-authorized.decorator';
|
import { JwtAuthorized } from '@/modules/iam/common/decorators/jwt-authorized.decorator';
|
||||||
import { AuthData } from '@/modules/iam/common/types/auth-data';
|
import { AuthData } from '@/modules/iam/common/types/auth-data';
|
||||||
@@ -79,17 +78,14 @@ export class ChatController {
|
|||||||
description: 'Get current user chat with pagination and provider filter',
|
description: 'Get current user chat with pagination and provider filter',
|
||||||
})
|
})
|
||||||
@ApiQuery({ name: 'providerId', description: 'Provider ID', required: false })
|
@ApiQuery({ name: 'providerId', description: 'Provider ID', required: false })
|
||||||
@ApiQuery({ name: 'limit', description: 'Limit for pagination', required: false })
|
|
||||||
@ApiQuery({ name: 'offset', description: 'Offset for pagination', required: false })
|
|
||||||
@ApiQuery({ name: 'cursor', description: 'Cursor position for pagination', required: false })
|
|
||||||
@ApiOkResponse({ description: 'Chat list', type: [ChatDto] })
|
@ApiOkResponse({ description: 'Chat list', type: [ChatDto] })
|
||||||
@Get()
|
@Get()
|
||||||
async getChats(
|
async getChatsByCursor(
|
||||||
@CurrentAuth() { accountId, user }: AuthData,
|
@CurrentAuth() { accountId, user }: AuthData,
|
||||||
@Query() paging: ChatPagingQuery,
|
@Query() paging: CursorPagingQuery,
|
||||||
@Query('providerId') providerId: number | null,
|
@Query('providerId') providerId: number | null,
|
||||||
) {
|
) {
|
||||||
return this.service.getChats(accountId, user, providerId, paging);
|
return this.service.getChatsByCursor(accountId, user, providerId, paging);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { Repository, type SelectQueryBuilder } from 'typeorm';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BadRequestError,
|
BadRequestError,
|
||||||
|
CursorPagingQuery,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
PagingMeta,
|
PagingMeta,
|
||||||
type PagingQuery,
|
type PagingQuery,
|
||||||
} from '@/common';
|
} from '@/common';
|
||||||
import { ChatPagingQuery } from '@/common/dto/paging/chat-paging-query.dto';
|
|
||||||
|
|
||||||
import { Account } from '@/modules/iam/account/entities/account.entity';
|
import { Account } from '@/modules/iam/account/entities/account.entity';
|
||||||
import { AccountService } from '@/modules/iam/account/account.service';
|
import { AccountService } from '@/modules/iam/account/account.service';
|
||||||
@@ -575,11 +575,11 @@ export class ChatService {
|
|||||||
return chat;
|
return chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChats(
|
async getChatsByCursor(
|
||||||
accountId: number,
|
accountId: number,
|
||||||
user: User,
|
user: User,
|
||||||
providerId: number | null | undefined,
|
providerId: number | null | undefined,
|
||||||
paging: ChatPagingQuery,
|
paging: CursorPagingQuery,
|
||||||
): Promise<Chat[]> {
|
): Promise<Chat[]> {
|
||||||
const providers = await this.chatProviderService.findMany(accountId, user.id, {
|
const providers = await this.chatProviderService.findMany(accountId, user.id, {
|
||||||
providerId: providerId ?? undefined,
|
providerId: providerId ?? undefined,
|
||||||
@@ -587,58 +587,33 @@ export class ChatService {
|
|||||||
if (providers.length === 0) {
|
if (providers.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const cursorChat = paging.cursor ? await this.findOne({ accountId, filter: { chatId: paging.cursor } }) : null;
|
||||||
|
|
||||||
|
const lastMessageCreatedAt = cursorChat
|
||||||
|
? await this.chatMessageService.getLastMessageCreatedAt(accountId, cursorChat.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const from = lastMessageCreatedAt ?? cursorChat?.createdAt;
|
||||||
|
|
||||||
const qb = this.createFindQb(accountId, user.id, { providerId: providers.map((p) => p.id) }, true);
|
const qb = this.createFindQb(accountId, user.id, { providerId: providers.map((p) => p.id) }, true);
|
||||||
|
if (from) {
|
||||||
// Check if cursor-based pagination is requested
|
qb.andWhere('COALESCE(last_msg.created_at, chat.created_at) < :from', { from });
|
||||||
if (paging.cursor) {
|
|
||||||
// Cursor-based pagination logic
|
|
||||||
const cursorChat = await this.findOne({ accountId, filter: { chatId: paging.cursor } });
|
|
||||||
const lastMessageCreatedAt = cursorChat
|
|
||||||
? await this.chatMessageService.getLastMessageCreatedAt(accountId, cursorChat.id)
|
|
||||||
: null;
|
|
||||||
const from = lastMessageCreatedAt ?? cursorChat?.createdAt;
|
|
||||||
|
|
||||||
if (from) {
|
|
||||||
qb.andWhere('COALESCE(last_msg.created_at, chat.created_at) < :from', { from });
|
|
||||||
}
|
|
||||||
|
|
||||||
const chats = await qb.orderBy('chat_updated_at', 'DESC').addOrderBy('chat.id', 'DESC').take(paging.take).getMany();
|
|
||||||
|
|
||||||
for (const chat of chats) {
|
|
||||||
chat.users = await this.chatUserService.findMany(accountId, { chatId: chat.id });
|
|
||||||
if (chat.lastMessage) {
|
|
||||||
chat.lastMessage = await this.chatMessageService.getLastMessageInfo(accountId, chat.id, chat.lastMessage.id);
|
|
||||||
}
|
|
||||||
if (user && chat.entityId) {
|
|
||||||
chat.entityInfo = await this.entityInfoService.findOne({ accountId, user, entityId: chat.entityId });
|
|
||||||
}
|
|
||||||
chat.hasAccess = chat.users.some((u) => u.userId === user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return chats;
|
|
||||||
} else {
|
|
||||||
// Offset-based pagination logic (default)
|
|
||||||
const chats = await qb
|
|
||||||
.orderBy('chat_updated_at', 'DESC')
|
|
||||||
.addOrderBy('chat.id', 'DESC')
|
|
||||||
.offset(paging.skip)
|
|
||||||
.limit(paging.take)
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
for (const chat of chats) {
|
|
||||||
chat.users = await this.chatUserService.findMany(accountId, { chatId: chat.id });
|
|
||||||
if (chat.lastMessage) {
|
|
||||||
chat.lastMessage = await this.chatMessageService.getLastMessageInfo(accountId, chat.id, chat.lastMessage.id);
|
|
||||||
}
|
|
||||||
if (user && chat.entityId) {
|
|
||||||
chat.entityInfo = await this.entityInfoService.findOne({ accountId, user, entityId: chat.entityId });
|
|
||||||
}
|
|
||||||
chat.hasAccess = chat.users.some((u) => u.userId === user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return chats;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chats = await qb.orderBy('chat_updated_at', 'DESC').addOrderBy('chat.id', 'DESC').take(paging.take).getMany();
|
||||||
|
|
||||||
|
for (const chat of chats) {
|
||||||
|
chat.users = await this.chatUserService.findMany(accountId, { chatId: chat.id });
|
||||||
|
if (chat.lastMessage) {
|
||||||
|
chat.lastMessage = await this.chatMessageService.getLastMessageInfo(accountId, chat.id, chat.lastMessage.id);
|
||||||
|
}
|
||||||
|
if (user && chat.entityId) {
|
||||||
|
chat.entityInfo = await this.entityInfoService.findOne({ accountId, user, entityId: chat.entityId });
|
||||||
|
}
|
||||||
|
chat.hasAccess = chat.users.some((u) => u.userId === user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chats;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUnseenForUser(accountId: number, userId: number, providerId?: number): Promise<number> {
|
async getUnseenForUser(accountId: number, userId: number, providerId?: number): Promise<number> {
|
||||||
|
|||||||
@@ -55,19 +55,10 @@ export class VoximplantCoreService {
|
|||||||
this._viConfig = this.configService.get<VoximplantConfig>('voximplant');
|
this._viConfig = this.configService.get<VoximplantConfig>('voximplant');
|
||||||
|
|
||||||
const credentialsPath = `${process.cwd()}${this._viConfig.credentialsFile}`;
|
const credentialsPath = `${process.cwd()}${this._viConfig.credentialsFile}`;
|
||||||
try {
|
this.client = new VoximplantApiClient(credentialsPath);
|
||||||
this.client = new VoximplantApiClient({ pathToCredentials: credentialsPath });
|
|
||||||
} catch (e) {
|
|
||||||
this.logger.warn(`Failed to initialize Voximplant client: ${(e as Error).message}`);
|
|
||||||
this.client = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createChildAccount(account: Account): Promise<ChildAccount | null> {
|
public async createChildAccount(account: Account): Promise<ChildAccount | null> {
|
||||||
if (!this.client) {
|
|
||||||
this.logger.warn('Voximplant client not initialized');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const accountSettings = await this.accountSettingsService.getOne(account.id);
|
const accountSettings = await this.accountSettingsService.getOne(account.id);
|
||||||
const owner = await this.userService.findOne({ accountId: account.id, role: UserRole.OWNER });
|
const owner = await this.userService.findOne({ accountId: account.id, role: UserRole.OWNER });
|
||||||
const accountName = `${this._appName}-${account.subdomain}`.substring(0, VOXIMPLANT_ACCOUNT_NAME_MAX).toLowerCase();
|
const accountName = `${this._appName}-${account.subdomain}`.substring(0, VOXIMPLANT_ACCOUNT_NAME_MAX).toLowerCase();
|
||||||
@@ -111,10 +102,6 @@ export class VoximplantCoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getChildrenAccounts() {
|
public async getChildrenAccounts() {
|
||||||
if (!this.client) {
|
|
||||||
this.logger.warn('Voximplant client not initialized');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return await this.client.Accounts.getChildrenAccounts({});
|
return await this.client.Accounts.getChildrenAccounts({});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,10 +111,6 @@ export class VoximplantCoreService {
|
|||||||
childAccountEmail: string,
|
childAccountEmail: string,
|
||||||
isActive: boolean,
|
isActive: boolean,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!this.client) {
|
|
||||||
this.logger.warn('Voximplant client not initialized');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const { result } = await this.client.Accounts.setChildAccountInfo({
|
const { result } = await this.client.Accounts.setChildAccountInfo({
|
||||||
childAccountId,
|
childAccountId,
|
||||||
childAccountName,
|
childAccountName,
|
||||||
@@ -160,10 +143,6 @@ export class VoximplantCoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getKeys() {
|
public async getKeys() {
|
||||||
if (!this.client) {
|
|
||||||
this.logger.warn('Voximplant client not initialized');
|
|
||||||
return { roles: [], keys: [] };
|
|
||||||
}
|
|
||||||
const { result: keys } = await this.client.RoleSystem.getKeys({});
|
const { result: keys } = await this.client.RoleSystem.getKeys({});
|
||||||
const { result: roles } = await this.client.RoleSystem.getRoles({});
|
const { result: roles } = await this.client.RoleSystem.getRoles({});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { writeSnapshot } from 'heapdump';
|
||||||
|
|
||||||
import { DateUtil } from '@/common';
|
import { DateUtil } from '@/common';
|
||||||
|
|
||||||
@@ -15,12 +16,7 @@ export class HeapdumpService {
|
|||||||
|
|
||||||
public async writeSnapshot(code: string) {
|
public async writeSnapshot(code: string) {
|
||||||
if (this._config?.accessCode && this._config.accessCode === code) {
|
if (this._config?.accessCode && this._config.accessCode === code) {
|
||||||
try {
|
writeSnapshot(`heapdump-${DateUtil.now().toISOString()}.heapsnapshot`);
|
||||||
const { writeSnapshot } = await import('heapdump');
|
|
||||||
writeSnapshot(`heapdump-${DateUtil.now().toISOString()}.heapsnapshot`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Heapdump not available:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
"incremental": true,
|
|
||||||
"tsBuildInfoFile": "./dist/.tsbuildinfo",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noEmit": false
|
|
||||||
},
|
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"test",
|
|
||||||
"**/*.spec.ts",
|
|
||||||
"**/*.test.ts",
|
|
||||||
"**/*.e2e-spec.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
12
backend/types/form-data.d.ts
vendored
Normal file
12
backend/types/form-data.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Form-data type declarations
|
||||||
|
declare module 'form-data' {
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
class FormData {
|
||||||
|
append(name: string, value: any, options?: any): void;
|
||||||
|
getHeaders(userHeaders?: any): any;
|
||||||
|
submit(params: any, callback?: any): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export = FormData;
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
-----BEGIN PRIVATE KEY-----
|
|
||||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCj4+xjbxSELVpp
|
|
||||||
1jKE140Q+xk8ckA6+DlC/n57HYOp1mNPKm3i0i+SN5cD28GQzjW72pcpYZ8YleYE
|
|
||||||
oG9epoCyAodc0P8Bf6zqQhQoLP26idJbv+m6RhbEAoYTUcWz5IrIZ4aqE51HM3oJ
|
|
||||||
kOo2tDrug/zSX9zq+zS++YyBZOLkdc7NJ/0YgKJcvEsHbJuuxAzn6VszX8ClWQnm
|
|
||||||
uwUQ4voQaV2rTr7BvE2Y0J0PZMgDAt5fSD1x0wvIfUXO3/8MGe/uDLDQL4e3ztcE
|
|
||||||
VZgb2r28P1k85c9XcpCkdVN0moKyzrPKq+bcDBIbBV9Q5f3r7d4EYEnDalpHpgEY
|
|
||||||
f67yXI4DAgMBAAECggEASTJEo2w7B4WR+e72hSoYENt0u/BzC2NNf8RWDPpzkWj0
|
|
||||||
1ainh0REhtNZGRoO63ONwCaymILHIZ3hK3PUCbvngplqh2O4YJz7R2zXv9HISIXB
|
|
||||||
c8TUyKMBC+3sn7hHyj5qVXMXS+KSvfgZqygT0vbP0zMTuYmjCzfCqQCfZjL+uvXD
|
|
||||||
oBz+TcCLK68dJSB5CoAh5EAs7FGMDIFAxYvBv96zQGrEuvfpucFc7v2XznChjGgb
|
|
||||||
sE9D8gg927z1PUbQsWv8SOvDHwHBqix9f8ph1phasmFNXN5ZAS7lYoIGX0Hro6T2
|
|
||||||
RseW5h38toC3Lc4U9wmLIFexiegNsern4xRkdFnX4QKBgQDUQ79+RbjkkFX5zaCA
|
|
||||||
JOUPKBY+M1eGJIp0a4BS1/SNDTUOnLd6yQHoujKy61tJdSOS+jwSJeF0fhGF4r1u
|
|
||||||
PjarekW0i1hbUVK//KM1Q8OsHX5Tk/tsPYVLw3HFQpoiTvZOeGYItguMoMmQnmTK
|
|
||||||
iN8ZR4MJYHbpl5nOxjIOVvYA6wKBgQDFqJdw7Y22GY5HrEC6M+vKynWbSlFgz5S5
|
|
||||||
xVtpuFLtRhy8lCOZq1me2oBl6oS+y1xYoft/KTPM4ygZLwH10f4V7qkOQwN+OuAC
|
|
||||||
SB8+eBK4LO54KbtDkRXYpEkmUJNSVHtWXiJGEqXVgCIvF8YvBYWXpMCwBqXHHhGs
|
|
||||||
Ygs7CkghSQKBgB1lVHu0RCrDImT56SRV97Llpk7u5Uwae2IsERVn+uId1h8z7OUA
|
|
||||||
OVd1kdfdaEMACfEs3mzU+igb3WlhQUKnMwMEZ+rc8VuUI5Wa8y9JNyv62afRcpxG
|
|
||||||
2NLpOjRLSPU/YjTzz42dSHQtQDza8rJpyhvCH4+I4G7xI8fTAtOhj2gJAoGACS4/
|
|
||||||
entOLbsaJLIXf46R0SV+OOxGw1xg6BAGou5wy5yKEShATw7qZrp3ZER0TfhcHbHI
|
|
||||||
YKulQEr8vc61JJnQV2xyZbsvGlnZtcFr0hb5p5xOpz4o+IZwoVNgImtzrEtIP0a4
|
|
||||||
CNEs6rG85LsR9XUoM1bvrD1izdDTuVIEe4WKvCECgYBEX5pIxbr3+ydEmkn/Wv/p
|
|
||||||
FfwBn9BcqYJ8ACNN2FwWv2uUYI+AyZnt+FpqfrIlG7yDNp4GJzTkxbZToj7IlT7u
|
|
||||||
45KgJwmd1GDfpdjPbs0lXFKoWacr/sOLhCkbtZaXAJWX96IiJCDoY3HpeWUg9Usn
|
|
||||||
kNr7g4gv0qhxwwImd5ubRQ==
|
|
||||||
-----END PRIVATE KEY-----
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-----BEGIN PUBLIC KEY-----
|
|
||||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo+PsY28UhC1aadYyhNeN
|
|
||||||
EPsZPHJAOvg5Qv5+ex2DqdZjTypt4tIvkjeXA9vBkM41u9qXKWGfGJXmBKBvXqaA
|
|
||||||
sgKHXND/AX+s6kIUKCz9uonSW7/pukYWxAKGE1HFs+SKyGeGqhOdRzN6CZDqNrQ6
|
|
||||||
7oP80l/c6vs0vvmMgWTi5HXOzSf9GICiXLxLB2ybrsQM5+lbM1/ApVkJ5rsFEOL6
|
|
||||||
EGldq06+wbxNmNCdD2TIAwLeX0g9cdMLyH1Fzt//DBnv7gyw0C+Ht87XBFWYG9q9
|
|
||||||
vD9ZPOXPV3KQpHVTdJqCss6zyqvm3AwSGwVfUOX96+3eBGBJw2paR6YBGH+u8lyO
|
|
||||||
AwIDAQAB
|
|
||||||
-----END PUBLIC KEY-----
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
6509
backend/yarn.lock
6509
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
3838
crm.mcmed.ru.txt
3838
crm.mcmed.ru.txt
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user