Implement first part of login route

feat/api
Lea 2023-07-13 23:03:37 +02:00
parent c15ef3b936
commit f947df4610
14 changed files with 305 additions and 22 deletions

View File

@ -11,3 +11,6 @@ MONGODB_PASSWORD=
MOBGODB_DATABASE=automod
REDIS_URI=redis://
PREFIX=/
# Set this to update the geoip database automatically. See docs of `geoip-lite` for more info
MAXMIND_API_KEY=

1
api/.env Symbolic link
View File

@ -0,0 +1 @@
../.env

View File

@ -24,9 +24,11 @@
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.0.12",
"@types/geoip-lite": "^1.4.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"dotenv": "^16.0.3",
"geoip-lite": "^1.4.7",
"lib": "workspace:*",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",

View File

@ -1,4 +1,32 @@
import { Controller } from '@nestjs/common';
import { Body, Controller, Ip, Post } from '@nestjs/common';
import { ApiProperty, ApiResponse, ApiTags } from '@nestjs/swagger';
import { NoAuthentication } from './authdata.decorator';
import { AuthService } from './auth.service';
export class LoginData {
@ApiProperty({ description: 'The user ID you are trying to log in as' })
user: string;
}
export class LoginResponse {
@ApiProperty()
token: string;
@ApiProperty()
otp: string;
}
@Controller('auth')
export class AuthController {}
@ApiTags('authentication')
export class AuthController {
constructor(private auth: AuthService) {}
@Post('login')
@NoAuthentication()
@ApiResponse({ type: LoginResponse })
async login(
@Body() data: LoginData,
@Ip() ip: string,
): Promise<LoginResponse> {
return await this.auth.createLoginRequest(data.user, ip);
}
}

View File

@ -1,6 +1,7 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
@ -70,3 +71,15 @@ export class AuthGuard implements CanActivate {
return token;
}
}
export class NoAuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request: Request = context.switchToHttp().getRequest();
if (request.header('authorization')) {
throw new ForbiddenException(
'This route may not be called with authentication',
);
}
return true;
}
}

View File

@ -1,6 +1,15 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { DBAuthToken } from 'lib';
import {
BadRequestException,
Injectable,
InternalServerErrorException,
UnauthorizedException,
} from '@nestjs/common';
import { DBAuthToken, RE_ULID, generateAuthToken, generateOTP } from 'lib';
import { ulid } from 'ulid';
import { lookup as ipLookup } from 'geoip-lite';
import { DatabaseService } from 'src/database/database.service';
import { LoginResponse } from './auth.controller';
import { UserAuditLogType } from 'lib/dist/cjs/types/database/user_audit_log';
@Injectable()
export class AuthService {
@ -15,6 +24,65 @@ export class AuthService {
if (tokenData.expires && tokenData.expires.getTime() <= Date.now()) {
throw new UnauthorizedException('Token expired');
}
if (tokenData.pending) {
throw new UnauthorizedException('Token not activated yet');
}
return tokenData;
}
async createLoginRequest(user: string, ip: string): Promise<LoginResponse> {
if (!new RegExp(RE_ULID).test(user)) {
throw new BadRequestException(
"The provided user doesn't appear to be a valid user ID",
);
}
const tokenId = ulid();
const token = generateAuthToken();
let otp: string | undefined = undefined;
// Eliminate possibility of duplicate OTPs
for (let i = 0; i < 10; i++) {
const generated = generateOTP();
const count = await this.db.getDb().authTokens.countDocuments({
otp: generated,
user: user,
});
if (!count) {
otp = generated;
break;
}
}
if (!otp) {
throw new InternalServerErrorException('Unable to find a free OTP');
}
await this.db.getDb().authTokens.insertOne({
_id: tokenId,
user: user,
token: token,
otp: otp,
expires: new Date(Date.now() + 1000 * 60 * 5),
});
const ipData = ipLookup(ip);
await this.db.getDb().userAuditLog.insertOne({
_id: ulid(),
user: user,
type: UserAuditLogType.LoginAttempt,
location: {
country: ipData?.country,
region: ipData?.region,
city: ipData?.city,
},
});
return {
token: token,
otp: otp,
};
}
}

View File

@ -6,7 +6,7 @@ import {
SetMetadata,
UseGuards,
} from '@nestjs/common';
import { AuthenticationData, AuthGuard } from './auth.guard';
import { AuthenticationData, AuthGuard, NoAuthGuard } from './auth.guard';
import {
ApiBearerAuth,
ApiForbiddenResponse,
@ -45,3 +45,12 @@ export function Auth() {
export function ServerAuth(level: 'member' | 'moderator' | 'administrator') {
return applyDecorators(SetMetadata('access_level', level));
}
export function NoAuthentication() {
return applyDecorators(
UseGuards(NoAuthGuard),
ApiForbiddenResponse({
description: 'Access to the resource is forbidden',
}),
);
}

View File

@ -9,15 +9,16 @@ import { META } from './meta';
import { checkEnv } from 'lib';
import { config } from 'dotenv';
import { Logger, ValidationPipe } from '@nestjs/common';
import { maxmindUpdater } from './util';
config();
if (process.env.NODE_ENV != 'production') {
Logger.log('$NODE_ENV is not set; Loading .env.dev');
config({ path: '../.env.dev' });
}
checkEnv(['MONGODB_URI', 'MONGODB_DATABASE', 'REDIS_URI']);
async function bootstrap() {
config();
if (process.env.NODE_ENV != 'production') {
Logger.log('$NODE_ENV is not set; Loading .env.dev');
config({ path: '../.env.dev' });
}
checkEnv(['MONGODB_URI', 'MONGODB_DATABASE', 'REDIS_URI']);
const app = await NestFactory.create(AppModule);
app.getHttpAdapter().getInstance().disable('x-powered-by');
@ -43,5 +44,8 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe({ skipMissingProperties: false }));
await app.listen(3000);
maxmindUpdater();
}
bootstrap();

43
api/src/util.ts Normal file
View File

@ -0,0 +1,43 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Logger } from '@nestjs/common';
import { startWatchingDataUpdate } from 'geoip-lite';
import { exec } from 'child_process';
export function maxmindUpdater() {
startWatchingDataUpdate(() => {
Logger.log(`GeoIP dataset has been updated`);
});
const apiKey = process.env.MAXMIND_API_KEY?.trim();
if (apiKey?.match(/\s/g)) {
// This should protect against command injection in case an attacker somehow controls the API key variable
throw new Error(`Maxmind API key includes a whitespace`);
}
if (apiKey) {
Logger.log(
`Maxmind API key was provided; Background geoip updates are enabled`,
);
async function update() {
Logger.log(`Checking for GeoIP updates`);
const process = exec(
`cd node_modules/geoip-lite && npm run-script updatedb license_key=${apiKey}`,
);
process.on('close', (code) => {
if (code == 0) Logger.log('GeoIP update completed with no error');
else Logger.warn('GeoIP update exited with exit code ' + code);
});
}
if (process.env.NODE_ENV != 'development') {
// GeoIP only updates files if the hash has changed, so it's fine to run this on every startup
setTimeout(update, 1000 * 30);
setInterval(update, 1000 * 60 * 60 * 24);
} else {
Logger.log(
`Not scheduling GeoIP update tasks as we're running in development mode`,
);
}
}
}

View File

@ -9,6 +9,7 @@ import { DBServerLog } from "../types/database/server_logs.js";
import { DBMember } from "../types/database/members.js";
import { DBIncrementingIDs } from "../types/database/incrementing_ids.js";
import { DBAuthToken } from "../types/database/auth_tokens.js";
import { DBUserAuditLog } from "../types/database/user_audit_log.js";
export async function connectDb(uri: string, database: string) {
const logger = createLogger();
@ -23,6 +24,7 @@ export async function connectDb(uri: string, database: string) {
return {
incrementingIDs:
db.collection<DBIncrementingIDs>("incrementing_ids"),
userAuditLog: db.collection<DBUserAuditLog>("user_audit_log"),
timedActions: db.collection<DBTimedAction>("timed_actions"),
infractions: db.collection<DBInfraction>("infractions"),
serverLogs: db.collection<DBServerLog>("server_logs"),

View File

@ -2,13 +2,14 @@ import { fileURLToPath } from "url";
import { API, Channel, Message, Server, ServerMember, User } from "revolt.js";
import path from "path";
import fs from "fs/promises";
import crypto from "crypto";
import { AutomodClient } from "../types/AutomodClient";
import { RE_MENTION_USER, RE_ULID } from "./regex.js";
import { DBInfraction } from "../types/database/infraction";
import { EventProtocol } from "revolt.js/src/events";
import { inspect } from "util";
import { OrID } from "../types/OrID";
import { AutomodDatabase } from "./db";
import { createLogger } from "./logger";
/**
* Replacement for __dirname which the nodejs people so graciously took away.
@ -574,3 +575,11 @@ export const limitLength = (input: string, max: number) =>
export const memberIsModerator = async (
member: ServerMember | null | undefined,
) => (member ? member.hasPermission(member.server!, "KickMembers") : false);
export const generateAuthToken = () => {
return crypto.randomBytes(64).toString("base64");
};
export const generateOTP = () => {
return crypto.randomBytes(3).toString("hex");
};

View File

@ -5,12 +5,18 @@ export type DBAuthToken = {
// The actual token
token: string;
// If pending, this OTP can be used to activate the token.
otp?: string;
// User the token belongs to
user: string;
// Token expiry data
expires?: Date;
// Whether this token is pending activation.
pending?: boolean;
// If true, this token will have unrestricted access to everything.
god?: boolean;
};

View File

@ -0,0 +1,25 @@
export type DBUserAuditLog = {
// Event ID, ULID
_id: string;
// Associated user
user: string;
// Type of event
type: UserAuditLogType;
location?: {
// 2 letter ISO-3166-1 country code
country?: string;
// 2 or 3 letter region code
region?: string;
// City name
city?: string;
};
};
export enum UserAuditLogType {
LoginAttempt = "login_attempt",
LoginAttemptSuccessful = "login_attempt_successful",
LoginAttemptFailed = "login_attempt_failed",
}

View File

@ -20,6 +20,9 @@ importers:
'@nestjs/swagger':
specifier: ^7.0.12
version: 7.0.12(@nestjs/common@10.0.0)(@nestjs/core@10.0.0)(class-transformer@0.5.1)(class-validator@0.14.0)(reflect-metadata@0.1.13)
'@types/geoip-lite':
specifier: ^1.4.1
version: 1.4.1
class-transformer:
specifier: ^0.5.1
version: 0.5.1
@ -29,6 +32,9 @@ importers:
dotenv:
specifier: ^16.0.3
version: 16.1.3
geoip-lite:
specifier: ^1.4.7
version: 1.4.7
lib:
specifier: workspace:*
version: link:../lib
@ -1609,6 +1615,10 @@ packages:
'@types/serve-static': 1.15.2
dev: true
/@types/geoip-lite@1.4.1:
resolution: {integrity: sha512-qHH5eF3rL1wwqpzdsgMdgskfdWXxxQvJb9POJ66NK7/1l3QXsqHLpIheh9OmhtqZ2CF7AmN0sA2R4PgW8JSm7w==}
dev: false
/@types/graceful-fs@4.1.6:
resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==}
dependencies:
@ -2282,6 +2292,12 @@ packages:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
dev: true
/async@2.6.4:
resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==}
dependencies:
lodash: 4.17.21
dev: false
/asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: true
@ -2444,7 +2460,6 @@ packages:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
dev: true
/brace-expansion@2.0.1:
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
@ -2486,6 +2501,10 @@ packages:
resolution: {integrity: sha512-ukmCZMneMlaC5ebPHXIkP8YJzNl5DC41N5MAIvKDqLggdao342t4McltoJBQfQya/nHBWAcSsYRqlXPoQkTJag==}
engines: {node: '>=14.20.1'}
/buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
dev: false
/buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@ -2705,7 +2724,6 @@ packages:
/concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true
/concat-stream@1.6.2:
resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==}
@ -3345,6 +3363,12 @@ packages:
bser: 2.1.1
dev: true
/fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
dependencies:
pend: 1.2.0
dev: false
/figures@3.2.0:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
engines: {node: '>=8'}
@ -3491,7 +3515,6 @@ packages:
/fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
dev: true
/fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
@ -3528,6 +3551,19 @@ packages:
engines: {node: '>=6.9.0'}
dev: true
/geoip-lite@1.4.7:
resolution: {integrity: sha512-JQHntlH7B/nR6Ec8ZJTuKsSdRNrR+snrfBNy0y0wVYWyVVi/MoDlXyv7P3wmozdlyshta6rXfbtK7qu/9lvEog==}
engines: {node: '>=5.10.0'}
dependencies:
async: 2.6.4
chalk: 4.1.2
iconv-lite: 0.4.24
ip-address: 5.9.4
lazy: 1.0.11
rimraf: 2.7.1
yauzl: 2.10.0
dev: false
/get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@ -3593,7 +3629,6 @@ packages:
minimatch: 3.1.2
once: 1.4.0
path-is-absolute: 1.0.1
dev: true
/glob@9.3.5:
resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}
@ -3802,7 +3837,6 @@ packages:
dependencies:
once: 1.4.0
wrappy: 1.0.2
dev: true
/inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@ -3867,6 +3901,15 @@ packages:
engines: {node: '>= 0.10'}
dev: true
/ip-address@5.9.4:
resolution: {integrity: sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==}
engines: {node: '>= 0.10'}
dependencies:
jsbn: 1.1.0
lodash: 4.17.21
sprintf-js: 1.1.2
dev: false
/ip@2.0.0:
resolution: {integrity: sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==}
@ -4542,6 +4585,10 @@ packages:
dependencies:
argparse: 2.0.1
/jsbn@1.1.0:
resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==}
dev: false
/jsesc@2.5.2:
resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==}
engines: {node: '>=4'}
@ -4602,6 +4649,11 @@ packages:
resolution: {integrity: sha512-RTSoaUAfLvpR357vWzAz/50Q/BmHfmE6ETSWfutT0AJiw10e6CmcdYRQJlLRd95B53D0Y2aD1jSxD3V3ySF+PA==}
dev: true
/lazy@1.0.11:
resolution: {integrity: sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==}
engines: {node: '>=0.2.0'}
dev: false
/leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@ -4811,7 +4863,6 @@ packages:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
dependencies:
brace-expansion: 1.1.11
dev: true
/minimatch@8.0.4:
resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==}
@ -4992,7 +5043,6 @@ packages:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
dependencies:
wrappy: 1.0.2
dev: true
/onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
@ -5118,7 +5168,6 @@ packages:
/path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
dev: true
/path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
@ -5154,6 +5203,10 @@ packages:
through: 2.3.8
dev: true
/pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
dev: false
/picocolors@1.0.0:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
dev: true
@ -5430,6 +5483,13 @@ packages:
- debug
dev: false
/rimraf@2.7.1:
resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
hasBin: true
dependencies:
glob: 7.2.3
dev: false
/rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
hasBin: true
@ -5668,6 +5728,10 @@ packages:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
dev: true
/sprintf-js@1.1.2:
resolution: {integrity: sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==}
dev: false
/stack-utils@2.0.6:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
@ -6445,7 +6509,6 @@ packages:
/wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
dev: true
/write-file-atomic@4.0.2:
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
@ -6506,6 +6569,13 @@ packages:
yargs-parser: 21.1.1
dev: true
/yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
dependencies:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
dev: false
/yn@3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'}