This commit is contained in:
Wheat Carrier 2024-03-19 20:19:27 +08:00
parent 335f98587e
commit 0d5c9c974d
21 changed files with 419 additions and 153 deletions

View File

@ -19,7 +19,7 @@
"input": "^1.0.1",
"ip": "^1.1.9",
"js-yaml": "^4.1.0",
"json-web-token": "^3.2.0",
"jsonwebtoken": "^9.0.2",
"telegraf": "^4.15.0",
"telegram": "^2.18.38",
"uuid": "^9.0.0",
@ -32,6 +32,7 @@
"@types/ip": "^1.1.0",
"@types/jest": "^29.5.2",
"@types/js-yaml": "^4.0.5",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.3.3",
"@types/supertest": "^2.0.12",
"@types/uuid": "^9.0.2",

View File

@ -5,7 +5,7 @@ import { IBot, TDLibApi } from 'src/api/interface';
import { config } from 'src/config';
import { TechnicalError } from 'src/errors/base';
import { MessageNotFound } from 'src/errors/telegram';
import { db } from 'src/server/manager/db';
import { manager } from 'src/server/manager';
import { Logger } from 'src/utils/logger';
import { getUploader } from './file-uploader';
@ -164,20 +164,28 @@ export class MessageApi extends MessageBroker {
name: string,
messageId: number,
): AsyncGenerator<Buffer> {
const task = db.createTask(name, 0, 'download');
let downloaded = 0;
for await (const buffer of this.tdlib.account.downloadFile({
const { chunks, size } = await this.tdlib.account.downloadFile({
chatId: this.privateChannelId,
messageId: messageId,
chunkSize: config.tgfs.download.chunk_size_kb,
})) {
yield buffer;
downloaded += buffer.length;
task.reportProgress(downloaded);
}
});
task.finish();
const task = manager.createDownloadTask(name, size);
task.begin();
try {
for await (const buffer of chunks) {
yield buffer;
downloaded += buffer.length;
task.reportProgress(downloaded);
}
} catch (err) {
task.setErrors([err]);
throw err;
} finally {
task.complete();
}
}
}

View File

@ -11,6 +11,7 @@ import { SendMessageResp, UploadedFile } from 'src/api/types';
import { Queue, generateFileId, getAppropriatedPartSize } from 'src/api/utils';
import { AggregatedError, TechnicalError } from 'src/errors/base';
import { FileTooBig } from 'src/errors/telegram';
import { manager } from 'src/server/manager';
import { Logger } from 'src/utils/logger';
import {
@ -125,6 +126,7 @@ export abstract class FileUploader<T extends GeneralFileMessage> {
big: 15,
},
): Promise<void> {
const task = manager.createUploadTask(this.fileName, this.fileSize);
this.prepare(file);
try {
this.fileName = fileName ?? this.defaultFileName;
@ -159,6 +161,9 @@ export abstract class FileUploader<T extends GeneralFileMessage> {
await Promise.all(promises);
} finally {
this.close();
task.errors = this.errors;
task.complete();
}
}

View File

@ -195,9 +195,9 @@ export class GramJSApi implements ITDLibClient {
};
}
public async *downloadFile(
public async downloadFile(
req: types.DownloadFileReq,
): types.DownloadFileResp {
): Promise<types.DownloadFileResp> {
const message = (
await this.getMessages({
chatId: req.chatId,
@ -208,17 +208,27 @@ export class GramJSApi implements ITDLibClient {
const chunkSize = req.chunkSize * 1024;
let i = 0;
for await (const chunk of this.client.iterDownload({
file: new Api.InputDocumentFileLocation({
id: message.document.id,
accessHash: message.document.accessHash,
fileReference: message.document.fileReference,
thumbSize: '',
}),
requestSize: chunkSize,
})) {
i += 1;
yield Buffer.from(chunk);
const client = this.client;
async function* chunks() {
for await (const chunk of client.iterDownload({
file: new Api.InputDocumentFileLocation({
id: message.document.id,
accessHash: message.document.accessHash,
fileReference: message.document.fileReference,
thumbSize: '',
}),
requestSize: chunkSize,
})) {
i += 1;
yield Buffer.from(chunk);
}
}
return {
chunks: chunks(),
size: Number(message.document.size),
};
}
}

View File

@ -22,7 +22,7 @@ export interface ITDLibClient {
sendSmallFile(req: types.SendFileReq): Promise<types.SendMessageResp>;
downloadFile(req: types.DownloadFileReq): types.DownloadFileResp;
downloadFile(req: types.DownloadFileReq): Promise<types.DownloadFileResp>;
}
export interface IBot {

View File

@ -83,4 +83,7 @@ export type DownloadFileReq = Chat &
chunkSize: number;
};
export type DownloadFileResp = AsyncGenerator<Buffer>;
export type DownloadFileResp = {
chunks: AsyncGenerator<Buffer>;
size: number;
};

View File

@ -45,7 +45,7 @@ export type Config = {
jwt: {
secret: string;
algorithm: string;
expiration: number;
life: number;
};
};
};
@ -108,7 +108,7 @@ export const loadConfig = (configPath: string): Config => {
jwt: {
secret: cfg['manager']['jwt']['secret'],
algorithm: cfg['manager']['jwt']['algorithm'] ?? 'HS256',
expiration: cfg['manager']['jwt']['expiration'],
life: cfg['manager']['jwt']['life'],
},
},
};
@ -212,7 +212,7 @@ export const createConfig = async (): Promise<string> => {
jwt: {
secret: generateRandomSecret(),
algorithm: 'HS256',
expiration: 3600 * 24 * 7,
life: 3600 * 24 * 7,
},
},
};

View File

@ -36,7 +36,7 @@ export class IncorrectPassword extends InvalidCredentials {
}
export class JWTInvalid extends InvalidCredentials {
constructor() {
super('JWT token invalid');
constructor(public readonly cause: string) {
super(`JWT token invalid, ${cause}`);
}
}

View File

@ -92,16 +92,18 @@ const { argv }: any = yargs(hideBin(process.argv))
}
}
startServer(
'Manager',
(req, res, next) => {
managerServer(req, res);
next();
},
config.manager.host,
config.manager.port,
config.manager.path,
);
managerServer.listen(config.manager.port, config.manager.host);
// startServer(
// 'Manager',
// (req, res, next) => {
// managerServer(req, res);
// next();
// },
// config.manager.host,
// config.manager.port,
// config.manager.path,
// );
startBot();
})();

View File

@ -1,48 +1,94 @@
import jwt from 'json-web-token';
import jwt from 'jsonwebtoken';
import { config } from 'src/config';
import { IncorrectPassword, JWTInvalid, UserNotFound } from 'src/errors';
import { TechnicalError } from 'src/errors/base';
const sha256 = async (s: string) => {
const msgBuffer = new TextEncoder().encode(s);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return hashHex;
};
import { Context, WithContext } from './model/context';
import { LoggerWithContext } from './utils/logger';
type JWTPayload = {
username: string;
user: string;
exp: number;
iat: number;
};
export const generateToken = async (username: string, password: string) => {
if (config.tgfs.users[username] === undefined) {
throw new UserNotFound(username);
}
if ((await sha256(config.tgfs.users[username].password)) !== password) {
// the password sent in is sha256 hashed
throw new IncorrectPassword(username);
}
return jwt.encode;
};
export class Auth extends WithContext {
private logger: LoggerWithContext;
export const verifyToken = (token: string): Promise<JWTPayload> => {
return new Promise((resolve, reject) => {
const payload = jwt.decode(
config.manager.jwt.secret,
token,
(error, payload) => {
if (error) {
reject(new JWTInvalid());
}
},
);
if (payload.exp < Date.now()) {
reject(new JWTInvalid());
constructor(protected readonly context: Context) {
super(context);
this.logger = new LoggerWithContext(context);
}
private static sha256 = async (s: string) => {
const msgBuffer = new TextEncoder().encode(s);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return hashHex;
};
public async generateToken(
username: string,
password: string,
): Promise<string> {
if (config.tgfs.users[username] === undefined) {
throw new UserNotFound(username);
}
resolve(payload as JWTPayload);
});
};
if (
(await Auth.sha256(config.tgfs.users[username].password)) !== password
) {
// the password sent in is sha256 hashed
throw new IncorrectPassword(username);
}
return new Promise((resolve, reject) => {
jwt.sign(
{
user: username,
iat: Math.floor(Date.now() / 1000),
},
config.manager.jwt.secret,
{
issuer: 'tgfs-manager',
audience: 'tgfs-manager',
subject: username,
expiresIn: config.manager.jwt.life,
algorithm: config.manager.jwt.algorithm,
} as jwt.SignOptions,
(error, token) => {
if (error) {
this.logger.error(error);
reject(new TechnicalError('token generation failed'));
} else {
this.context.user = username;
this.logger.info(`jwt token issued`);
resolve(token);
}
},
);
});
}
public async authenticate(token: string): Promise<JWTPayload> {
return new Promise((resolve, reject) => {
const payload = jwt.verify(
config.manager.jwt.secret,
token,
(error, decoded: JWTPayload) => {
if (error) {
this.logger.error(error);
reject(new JWTInvalid('verification failed'));
} else {
this.context.user = decoded.user;
resolve(decoded);
}
},
);
});
}
}

View File

@ -9,7 +9,11 @@ class Database {
this.uploadTasks = new Map();
}
createTask(fileName: string, totalSize: number, type: 'download' | 'upload') {
private createTask(
fileName: string,
totalSize: number,
type: 'download' | 'upload',
): Task {
const task = new Task(fileName, totalSize, type);
if (type === 'download') {
this.downloadTasks[task.id] = task;
@ -20,6 +24,14 @@ class Database {
return task;
}
createUploadTask(fileName: string, totalSize: number): Task {
return this.createTask(fileName, totalSize, 'upload');
}
createDownloadTask(fileName: string, totalSize: number): Task {
return this.createTask(fileName, totalSize, 'download');
}
getTasks() {
return {
download: this.downloadTasks,
@ -28,4 +40,4 @@ class Database {
}
}
export const db = new Database();
export const manager = new Database();

View File

@ -1,54 +1,114 @@
import express, { Request, Response } from 'express';
import express, { Response } from 'express';
import { v4 as uuid } from 'uuid';
import { BadAuthentication } from 'src/errors';
import { TechnicalError } from 'src/errors/base';
import { generateToken, verifyToken } from './auth';
import { db } from './db';
import { Auth } from './auth';
import { manager } from './db';
import { Request } from './model/request';
import { Logger } from './utils/logger';
const app = express();
app.use(async (req, res, next) => {
if (req.path === '/login') {
next();
}
const autoCatch = (
fn: (
req: Request,
res: Response,
next: (err?: Error) => void,
) => void | Promise<void>,
) => {
return async (req: Request, res: Response, next: (err?: Error) => void) => {
try {
await fn(req, res, next);
} catch (err) {
next(err);
}
};
};
const token = req.headers['authorization'];
if (token === undefined) {
res.redirect('/');
}
app.use(express.json());
try {
await verifyToken(token);
} catch (err) {
res.redirect('/');
}
app.use((req: Request & { reqId: string }, res, next) => {
req.id = uuid();
req.logger = Logger.ctx(req);
req.logger.info(req.method, req.path);
req.auth = new Auth(req);
next();
});
// set cors headers
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'DELETE, POST, GET, OPTIONS');
res.header('Access-Control-Allow-Headers', 'content-type, authorization');
res.header('Access-Control-Allow-Credentials', 'true');
next();
});
app.post('/login', (req, res) => {
const token = generateToken(req.body.username, req.body.password);
res.setHeader('Set-Cookie', `token=${token}; HttpOnly`);
res.end();
app.use(
autoCatch(async (req: Request, res, next) => {
if (req.method === 'OPTIONS') {
next();
} else if (req.method === 'POST' && req.path === '/login') {
next();
} else {
const token = req.headers.authorization.replace('Bearer ', '');
req.auth = new Auth(req);
await req.auth.authenticate(token);
next();
}
}),
);
app.options('*', (req, res, next) => {
res.status(200);
next();
});
app.get('/tasks', (req, res) => {
console.log(req.headers);
res.send(db.getTasks());
});
app.post(
'/login',
autoCatch(async (req, res, next) => {
const token = await req.auth.generateToken(
req.body.username,
req.body.password,
);
res.write(token);
next();
}),
);
app.use((err: Error, req: Request, res: Response, next: () => any) => {
if (err instanceof TechnicalError) {
const error = new err.httpError(err.message);
res.status(error.statusCode).send(error.message);
} else {
res.status(500).send('Internal Server Error');
}
app.get(
'/tasks',
autoCatch(async (req, res, next) => {
res.write(manager.getTasks());
next();
}),
);
app.use(
(err: Error, req: Request, res: Response, next: (err?: Error) => void) => {
req.logger.error(err);
next(err);
},
);
app.use(
(err: Error, req: Request, res: Response, next: (err?: Error) => void) => {
if (err instanceof TechnicalError) {
const error = new err.httpError(err.message);
res.status(error.statusCode);
} else {
res.status(500);
}
next();
},
);
app.use((req: Request, res) => {
req.logger.info(res.statusCode);
res.send();
});
export const managerServer = app;
T

View File

@ -1,2 +1,3 @@
export { startBot } from './bot';
export { manager } from './db';
export { managerServer } from './http-server';

View File

@ -0,0 +1,8 @@
export type Context = {
id: string;
user?: string;
};
export class WithContext {
constructor(protected readonly context: Context) {}
}

View File

@ -0,0 +1,13 @@
import { Request as _Request } from 'express';
import { Auth } from 'src/server/manager/auth';
import { Logger, LoggerWithContext } from 'src/server/manager/utils/logger';
import { Context } from './context';
export type Request = _Request &
Context & {
logger?: LoggerWithContext;
auth?: Auth;
};

View File

@ -5,9 +5,10 @@ export class Task {
fileName: string;
totalSize: number;
completedSize: number;
status: 'queuing' | 'in-progress' | 'completed';
status: 'queuing' | 'in-progress' | 'completed' | 'failed';
type: 'download' | 'upload';
beginTime: number;
errors: Error[] = [];
constructor(
fileName: string,
@ -26,10 +27,14 @@ export class Task {
this.status = 'in-progress';
}
finish() {
complete() {
this.status = 'completed';
}
setErrors(errors: Error[]) {
this.errors = errors;
}
reportProgress(size: number) {
this.completedSize = size;
}

View File

View File

@ -0,0 +1,32 @@
import { Context } from 'src/server/manager/model/context';
import { Logger as BaseLogger } from 'src/utils/logger';
export class Logger extends BaseLogger {
static override prefix() {
return '[Manager]';
}
static ctx(ctx: Context) {
return new LoggerWithContext(ctx);
}
}
export class LoggerWithContext {
constructor(private readonly ctx: Context) {}
fmtCtx() {
return `[${this.ctx.id}] [${this.ctx.user ?? '-'}]`;
}
info(...args: any[]) {
Logger.info(this.fmtCtx(), ...args);
}
error(err: string | Error) {
console.error(
`[${Logger.getTime()}] [ERROR] ${Logger.prefix()} ${this.fmtCtx()} ${Logger.errorMsg(
err,
)}`,
);
}
}

View File

@ -3,39 +3,41 @@ import { AggregatedError, BusinessError, TechnicalError } from '../errors/base';
export class Logger {
static tzOffset = new Date().getTimezoneOffset() * 60000;
static prefix() {
return '[TGFS]';
}
static getTime() {
return new Date(Date.now() - this.tzOffset).toISOString().slice(0, -1);
}
static debug(...args: any[]) {
if (process.env.DEBUG === 'true') {
console.debug(`[${this.getTime()}] [DEBUG]`, ...args);
console.debug(`[${this.getTime()}] [DEBUG] ${this.prefix()}`, ...args);
}
}
static info(...args: any[]) {
console.info(`[${this.getTime()}] [INFO]`, ...args);
console.info(`[${this.getTime()}] [INFO] ${this.prefix()}`, ...args);
}
static errorMsg(err: string | Error) {
if (err instanceof AggregatedError) {
err.errors.forEach((e) => this.errorMsg(e));
} else if (err instanceof BusinessError) {
return `${err.code} (message: ${err.message} \n${err.stack}) (cause: ${err.cause})`;
} else if (err instanceof TechnicalError) {
return `Technical Error: (message: ${err.message}) (cause: ${err.cause})\n${err.stack})`;
} else if (err instanceof Error) {
return `${err.name} ${err.message}\n${err.stack}`;
} else {
return `${this.prefix()} ${err}`;
}
}
static error(err: string | Error) {
if (err instanceof AggregatedError) {
err.errors.forEach((e) => this.error(e));
} else if (err instanceof BusinessError) {
console.error(
`[${this.getTime()}] [ERROR] ${err.code} ${err.name} ${err.message} \n${err.stack}`,
);
} else if (err instanceof TechnicalError) {
console.error(
`[${this.getTime()}] [ERROR] ${err.name} ${err.message} ${err.cause}\n${
err.stack
}`,
);
} else if (err instanceof Error) {
console.error(
`[${this.getTime()}] [ERROR] ${err.name} ${err.message}\n${err.stack}`,
);
} else {
console.error(`[${this.getTime()}] [ERROR] ${err}`);
}
console.error(
`[${this.getTime()}] [ERROR] ${this.prefix()} ${this.errorMsg(err)}`,
);
}
}

View File

@ -69,7 +69,7 @@ describe('config', () => {
jwt: {
secret: 'mock-secret',
algorithm: 'HS256',
expiration: 3600 * 24 * 7,
life: 3600 * 24 * 7,
},
});
});

View File

@ -1080,11 +1080,6 @@ base64-js@^1.3.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
base64-url@^2.3.2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/base64-url/-/base64-url-2.3.3.tgz#645b71455c75109511f27d98450327e455f488ec"
integrity sha512-dLMhIsK7OplcDauDH/tZLvK7JmUZK3A7KiQpjNzsBrM6Etw7hzNI1tLEywqJk9NnwkgWuFKSlx/IUO7vF6Mo8Q==
big-integer@^1.6.48:
version "1.6.52"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.52.tgz#60a887f3047614a8e1bffe5d7173490a97dc8c85"
@ -1165,6 +1160,11 @@ buffer-alloc@^1.2.0:
buffer-alloc-unsafe "^1.1.0"
buffer-fill "^1.0.0"
buffer-equal-constant-time@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==
buffer-fill@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
@ -2184,11 +2184,6 @@ is-typedarray@^1.0.0:
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==
is.object@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is.object/-/is.object-1.0.0.tgz#e4f4117e9f083b35c8df5cf817ea3efb0452fdfa"
integrity sha512-BdDP6tLXkf0nrCnksLobALJxkt2hmrVL6ge1oRuzGU4Lb9NpreEbhhuCcY6HMzx/qo3Dff9DJ3jf0x9+U0bNMQ==
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@ -2665,6 +2660,39 @@ json5@^2.2.3:
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
jsonwebtoken@^9.0.2:
version "9.0.2"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3"
integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==
dependencies:
jws "^3.2.2"
lodash.includes "^4.3.0"
lodash.isboolean "^3.0.3"
lodash.isinteger "^4.0.4"
lodash.isnumber "^3.0.3"
lodash.isplainobject "^4.0.6"
lodash.isstring "^4.0.1"
lodash.once "^4.0.0"
ms "^2.1.1"
semver "^7.5.4"
jwa@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==
dependencies:
buffer-equal-constant-time "1.0.1"
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jws@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
dependencies:
jwa "^1.4.1"
safe-buffer "^5.0.1"
kleur@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
@ -2687,11 +2715,46 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==
lodash.isboolean@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==
lodash.isinteger@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==
lodash.isnumber@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==
lodash.isstring@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==
lodash.memoize@4.x:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==
lodash.once@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
lodash@^4.17.21, lodash@^4.3.0, lodash@^4.6.1:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
@ -2817,7 +2880,7 @@ ms@2.1.2:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
ms@2.1.3:
ms@2.1.3, ms@^2.1.1:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
@ -3212,7 +3275,7 @@ rx-lite@^3.1.2:
resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102"
integrity sha512-1I1+G2gteLB8Tkt8YI1sJvSIfa0lWuRtC8GjvtyPBcLSF5jBCCJJqKrpER5JU5r6Bhe+i9/pK3VMuUcXu0kdwQ==
safe-buffer@5.2.1:
safe-buffer@5.2.1, safe-buffer@^5.0.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@ -3821,11 +3884,6 @@ xml-js@^1.6.2:
dependencies:
sax "^1.2.4"
xtend@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
y18n@^5.0.5:
version "5.0.8"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"