use zod for config validation

This commit is contained in:
Sylver 2024-01-07 12:55:22 +08:00
parent 805ebcd4b4
commit b0352724b2
20 changed files with 169 additions and 233 deletions

View File

@ -13,7 +13,7 @@
"build": "tsc --noEmit && tsup",
"lint": "eslint src --fix --cache",
"test": "vitest run",
"watch": "tsup --watch --onSuccess \"node dist/main.js --inspect --inspect-brk\""
"watch": "tsup --watch --onSuccess \"node dist/main.js\""
},
"dependencies": {
"@fastify/cookie": "^9.2.0",
@ -65,6 +65,7 @@
"@types/nodemailer": "^6.4.14",
"@types/passport-jwt": "^4.0.0",
"bytes": "^3.1.2",
"chalk": "^5.3.0",
"content-range": "^2.0.2",
"dedent": "^1.5.1",
"escape-string-regexp": "^5.0.0",
@ -80,7 +81,9 @@
"ts-node": "^10.9.2",
"tsup": "^8.0.1",
"typescript": "^5.3.3",
"vitest": "^1.1.3"
"vitest": "^1.1.3",
"zod": "^3.22.4",
"zod-validation-error": "^2.1.0"
},
"mikro-orm": {
"useTsNode": true,

View File

@ -1,82 +0,0 @@
import bytes from 'bytes';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsDefined,
IsEmail,
IsMimeType,
IsNumber,
IsOptional,
IsString,
IsUrl,
Max,
NotEquals,
ValidateNested,
} from 'class-validator';
import path from 'path';
import { expandMime } from '../helpers/expand-mime.js';
import { MicroConversion } from './MicroConversion.js';
import { MicroEmail } from './MicroEmail.js';
import { MicroHost } from './MicroHost.js';
import { MicroPurge } from './MicroPurge.js';
export class MicroConfig {
@IsUrl({ require_tld: false, require_protocol: true, protocols: ['postgresql', 'postgres'] })
databaseUrl: string;
@IsString()
@NotEquals('YOU_SHALL_NOT_PASS')
secret: string;
@IsEmail()
inquiries: string;
@IsNumber()
@Transform(({ value }) => bytes.parse(value))
uploadLimit = bytes.parse('50MB');
@IsNumber()
@IsOptional()
@Max(500000)
maxPasteLength = 500000;
@IsMimeType({ each: true })
@IsOptional()
@Transform(({ value }) => {
if (!value) return value;
const clean = expandMime(value);
return new Set(clean);
})
allowTypes?: Set<string>;
@IsString()
@Transform(({ value }) => path.resolve(value))
storagePath: string;
@IsBoolean()
restrictFilesToHost: boolean;
@ValidateNested()
@IsOptional()
@Type(() => MicroPurge)
purge?: MicroPurge;
@ValidateNested()
@IsOptional()
@Type(() => MicroEmail)
email: MicroEmail;
@ValidateNested({ each: true })
@IsOptional()
@Type(() => MicroConversion)
conversions?: MicroConversion[];
@ValidateNested({ each: true })
@IsDefined()
@Type(() => MicroHost)
hosts: MicroHost[];
get rootHost() {
return this.hosts[0]!;
}
}

View File

@ -1,21 +0,0 @@
import bytes from 'bytes';
import { Transform } from 'class-transformer';
import { IsMimeType, IsNumber, IsOptional, IsString } from 'class-validator';
import { expandMime } from '../helpers/expand-mime.js';
export class MicroConversion {
@IsString({ each: true })
@Transform(({ value }) => {
const clean = expandMime(value);
return new Set(clean);
})
from: Set<string>;
@IsMimeType()
to: string;
@IsNumber()
@IsOptional()
@Transform(({ value }) => bytes.parse(value))
minSize?: number;
}

View File

@ -1,9 +0,0 @@
import { IsEmail, IsObject } from 'class-validator';
export class MicroEmail {
@IsEmail()
from: string;
@IsObject()
smtp: Record<string, any>;
}

View File

@ -1,45 +0,0 @@
import { IsOptional, IsString, IsUrl, Matches } from 'class-validator';
import escapeString from 'escape-string-regexp';
import { HostService } from '../modules/host/host.service.js';
export class MicroHost {
constructor(url: string, tags?: string[], redirect?: string) {
this.url = url;
this.tags = tags;
this.redirect = redirect;
}
// https://regex101.com/r/ZR9rpp/1
@Matches(/^https?:\/\/[\d.:A-z{}-]+$/u)
url: string;
@IsString({ each: true })
@IsOptional()
tags?: string[];
@IsUrl({ require_protocol: true })
@IsOptional()
redirect?: string;
get normalised() {
return HostService.normaliseHostUrl(this.url);
}
get isWildcard() {
return this.url.includes('{{username}}');
}
private _pattern?: RegExp;
get pattern() {
if (this._pattern) return this._pattern;
this._pattern = MicroHost.getWildcardPattern(this.url);
return this._pattern;
}
static getWildcardPattern(url: string) {
const normalised = HostService.normaliseHostUrl(url);
const escaped = escapeString(normalised);
const pattern = escaped.replace('\\{\\{username\\}\\}', '(?<username>[a-z0-9-{}]+?)');
return new RegExp(`^(https?:\\/\\/)?${pattern}\\/?`, 'u');
}
}

View File

@ -1,14 +0,0 @@
import bytes from 'bytes';
import { Transform } from 'class-transformer';
import { IsNumber } from 'class-validator';
import ms from 'ms';
export class MicroPurge {
@IsNumber()
@Transform(({ value }) => bytes.parse(value))
overLimit: number;
@IsNumber()
@Transform(({ value }) => ms(value))
afterTime: number;
}

View File

@ -1,20 +1,100 @@
import { loadConfig } from '@ryanke/venera';
import { plainToClass } from 'class-transformer';
import { validateSync } from 'class-validator';
import { MicroConfig } from './classes/MicroConfig.js';
import bytes from 'bytes';
import c from 'chalk';
import { randomBytes } from 'crypto';
import dedent from 'dedent';
import escapeStringRegexp from 'escape-string-regexp';
import ms from 'ms';
import z, { any, array, boolean, number, record, strictObject, string, union } from 'zod';
import { fromZodError } from 'zod-validation-error';
import { expandMime } from './helpers/expand-mime.js';
import { HostService } from './modules/host/host.service.js';
export type MicroHost = ReturnType<typeof enhanceHost>;
const schema = strictObject({
databaseUrl: string().startsWith('postgresql://'),
secret: string().min(6),
inquiries: string().email(),
uploadLimit: string().transform(bytes.parse),
maxPasteLength: number().default(500000),
allowTypes: z
.union([array(string()), string()])
.optional()
.transform((value) => new Set(value ? expandMime(value) : [])),
storagePath: string(),
restrictFilesToHost: boolean().default(true),
purge: strictObject({
overLimit: string().transform(bytes.parse),
afterTime: string().transform(ms),
}).optional(),
email: strictObject({
from: string().email(),
smtp: record(string(), any()),
}).optional(),
conversions: array(
strictObject({
from: union([array(string()), string()]).transform((value) => new Set(expandMime(value))),
to: string(),
minSize: string().transform(bytes.parse).optional(),
}),
).optional(),
hosts: array(
strictObject({
url: z
.string()
.url()
.transform((value) => value.replace(/\/$/, '')),
tags: array(string()).optional(),
redirect: string().url().optional(),
}),
),
});
const data = loadConfig('micro');
const config = plainToClass(MicroConfig, data, { exposeDefaultValues: true });
const errors = validateSync(config, { forbidUnknownValues: true });
if (errors.length > 0) {
const clean = errors.map((error) => error.toString()).join('\n');
console.dir(config, { depth: null });
console.error(clean);
process.exit(1);
const result = schema.safeParse(data);
if (!result.success) {
console.dir({ data, error: result.error }, { depth: null });
const pretty = fromZodError(result.error);
throw new Error(pretty.toString());
}
if (config.rootHost.isWildcard) {
const getWildcardPattern = (url: string) => {
const normalised = HostService.normaliseHostUrl(url);
const escaped = escapeStringRegexp(normalised);
const pattern = escaped.replace('\\{\\{username\\}\\}', '(?<username>[a-z0-9-{}]+?)');
return new RegExp(`^(https?:\\/\\/)?${pattern}\\/?`, 'u');
};
const enhanceHost = (host: z.infer<typeof schema>['hosts'][0]) => {
const isWildcard = host.url.includes('{{username}}');
const normalised = HostService.normaliseHostUrl(host.url);
const pattern = getWildcardPattern(host.url);
return {
...host,
isWildcard,
normalised,
pattern,
};
};
export const config = result.data as Omit<z.infer<typeof schema>, 'hosts'>;
export const hosts = result.data.hosts.map(enhanceHost);
export const rootHost = hosts[0];
if (rootHost.isWildcard) {
throw new Error(`Root host cannot be a wildcard domain.`);
}
export { config };
const disallowed = new Set(['youshallnotpass', 'you_shall_not_pass', 'secret', 'test']);
if (disallowed.has(config.secret.toLowerCase())) {
const token = randomBytes(24).toString('hex');
throw new Error(
dedent`
${c.redBright.bold('Do not use the default secret.')}
Please generate a random, secure secret or you risk anyone being able to impersonate you.
If you're lazy, here is a random secret: ${c.underline(token)}
`,
);
}

View File

@ -1,11 +1,11 @@
/* eslint-disable sonarjs/no-duplicate-string */
import type { IdentifiedReference } from '@mikro-orm/core';
import { BeforeCreate, Entity, type EventArgs, Property } from '@mikro-orm/core';
import { BeforeCreate, Entity, Property, type EventArgs } from '@mikro-orm/core';
import { ObjectType } from '@nestjs/graphql';
import type { FastifyRequest } from 'fastify';
import { config } from '../config.js';
import type { ResourceLocations } from '../types/resource-locations.type.js';
import { config, hosts, rootHost } from '../config.js';
import type { User } from '../modules/user/user.entity.js';
import type { ResourceLocations } from '../types/resource-locations.type.js';
import { getHostFromRequest } from './get-host-from-request.js';
@Entity({ abstract: true })
@ -31,10 +31,10 @@ export abstract class Resource {
}
getHost() {
if (!this.hostname) return config.rootHost;
const match = config.hosts.find((host) => host.normalised === this.hostname || host.pattern.test(this.hostname!));
if (!this.hostname) return rootHost;
const match = hosts.find((host) => host.normalised === this.hostname || host.pattern.test(this.hostname!));
if (match) return match;
return config.rootHost;
return rootHost;
}
getBaseUrl() {
@ -42,7 +42,7 @@ export abstract class Resource {
const host = this.getHost();
const hasPlaceholder = host.url.includes('{{username}}');
if (hasPlaceholder) {
if (!owner) return config.rootHost.url;
if (!owner) return rootHost.url;
return host.url.replace('{{username}}', owner.username);
}
@ -58,7 +58,7 @@ export abstract class Resource {
if (!config.restrictFilesToHost) return true;
// root host can send all files
if (hostname === config.rootHost.normalised) return true;
if (hostname === rootHost.normalised) return true;
if (this.hostname === hostname) return true;
if (this.hostname?.includes('{{username}}')) {
// old files have {{username}} in the persisted hostname, migrating them

View File

@ -4,7 +4,7 @@ import { config } from '../config.js';
const transport = config.email && nodemailer.createTransport(config.email.smtp);
export const sendMail = (options: Omit<nodemailer.SendMailOptions, 'from'>) => {
if (!transport) {
if (!transport || !config.email) {
throw new Error('No SMTP configuration found');
}

View File

@ -1,6 +1,6 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { config } from '../config.js';
import { config, hosts, rootHost } from '../config.js';
import { UserId } from './auth/auth.decorators.js';
import { OptionalJWTAuthGuard } from './auth/guards/optional-jwt.guard.js';
import { UserService } from './user/user.service.js';
@ -26,10 +26,10 @@ export class AppController {
allowTypes: config.allowTypes ? [...config.allowTypes?.values()] : undefined,
email: !!config.email,
rootHost: {
url: config.rootHost.url,
normalised: config.rootHost.normalised,
url: rootHost.url,
normalised: rootHost.normalised,
},
hosts: config.hosts
hosts: hosts
.filter((host) => {
if (!host.tags || !host.tags[0]) return true;
return host.tags.every((tag) => tags.includes(tag));

View File

@ -1,7 +1,6 @@
import { UseGuards } from '@nestjs/common';
import { Query, Resolver } from '@nestjs/graphql';
import { MicroHost } from '../classes/MicroHost.js';
import { config } from '../config.js';
import { config, hosts, rootHost, type MicroHost } from '../config.js';
import type { ConfigHost } from '../types/config.type.js';
import { Config } from '../types/config.type.js';
import { CurrentHost, UserId } from './auth/auth.decorators.js';
@ -28,9 +27,9 @@ export class AppResolver {
uploadLimit: config.uploadLimit,
allowTypes: config.allowTypes ? [...config.allowTypes?.values()] : [],
requireEmails: !!config.email,
rootHost: this.filterHost(config.rootHost),
rootHost: this.filterHost(rootHost),
currentHost: this.filterHost(currentHost),
hosts: config.hosts
hosts: hosts
.filter((host) => {
if (!host.tags || !host.tags[0]) return true;
return host.tags.every((tag) => tags.includes(tag));

View File

@ -4,7 +4,7 @@ import { UseGuards } from '@nestjs/common';
import { Args, Context, Mutation, Resolver } from '@nestjs/graphql';
import type { FastifyReply } from 'fastify';
import ms from 'ms';
import { config } from '../../config.js';
import { rootHost } from '../../config.js';
import { User } from '../user/user.entity.js';
import { UserId } from './auth.decorators.js';
import { AuthService, TokenType } from './auth.service.js';
@ -18,13 +18,13 @@ export class AuthResolver {
private static readonly COOKIE_OPTIONS = {
path: '/',
httpOnly: true,
domain: config.rootHost.normalised.split(':').shift(),
secure: config.rootHost.url.startsWith('https'),
domain: rootHost.normalised.split(':').shift(),
secure: rootHost.url.startsWith('https'),
};
constructor(
@InjectRepository(User) private readonly userRepo: EntityRepository<User>,
private readonly authService: AuthService
private readonly authService: AuthService,
) {}
@Mutation(() => User)
@ -32,7 +32,7 @@ export class AuthResolver {
@Context() ctx: any,
@Args('username') username: string,
@Args('password') password: string,
@Args('otpCode', { nullable: true }) otpCode?: string
@Args('otpCode', { nullable: true }) otpCode?: string,
) {
const reply = ctx.reply as FastifyReply;
const user = await this.authService.authenticateUser(username, password, otpCode);

View File

@ -15,7 +15,7 @@ import {
UseGuards,
} from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { config } from '../../config.js';
import { rootHost } from '../../config.js';
import { UserId } from '../auth/auth.decorators.js';
import { JWTAuthGuard } from '../auth/guards/jwt.guard.js';
import { HostService } from '../host/host.service.js';
@ -31,14 +31,14 @@ export class FileController {
private readonly fileService: FileService,
private readonly userService: UserService,
private readonly hostService: HostService,
private readonly linkService: LinkService
private readonly linkService: LinkService,
) {}
@Get('file/:fileId')
async getFileContent(
@Res() reply: FastifyReply,
@Param('fileId') fileId: string,
@Request() request: FastifyRequest
@Request() request: FastifyRequest,
) {
return this.fileService.sendFile(fileId, request, reply);
}
@ -49,8 +49,8 @@ export class FileController {
@UserId() userId: string,
@Req() request: FastifyRequest,
@Headers('X-Micro-Paste-Shortcut') shortcut: string,
@Headers('x-micro-host') hosts = config.rootHost.url,
@Query('input') input?: string
@Headers('x-micro-host') hosts = rootHost.url,
@Query('input') input?: string,
) {
const user = await this.userService.getUser(userId, true);
const host = this.hostService.resolveUploadHost(hosts, user);

View File

@ -13,8 +13,7 @@ import { DateTime } from 'luxon';
import mime from 'mime-types';
import sharp from 'sharp';
import { PassThrough } from 'stream';
import type { MicroHost } from '../../classes/MicroHost.js';
import { config } from '../../config.js';
import { config, type MicroHost } from '../../config.js';
import { generateContentId } from '../../helpers/generate-content-id.helper.js';
import { getStreamType } from '../../helpers/get-stream-type.helper.js';
import { HostService } from '../host/host.service.js';
@ -29,7 +28,7 @@ export class FileService implements OnApplicationBootstrap {
@InjectRepository('File') private readonly fileRepo: EntityRepository<File>,
private readonly storageService: StorageService,
private readonly hostService: HostService,
protected readonly orm: MikroORM
protected readonly orm: MikroORM,
) {}
async getFile(id: string, request: FastifyRequest) {
@ -45,7 +44,7 @@ export class FileService implements OnApplicationBootstrap {
multipart: MultipartFile,
request: FastifyRequest,
owner: User,
host: MicroHost | undefined
host: MicroHost | undefined,
): Promise<File> {
if (host) this.hostService.checkUserCanUploadTo(host, owner);
if (!request.headers['content-length']) throw new BadRequestException('Missing "Content-Length" header.');
@ -53,7 +52,7 @@ export class FileService implements OnApplicationBootstrap {
if (Number.isNaN(contentLength) || contentLength >= config.uploadLimit) {
const size = bytes.parse(Number(request.headers['content-length']));
this.logger.warn(
`User ${owner.id} tried uploading a ${size} file, which is over the configured upload size limit.`
`User ${owner.id} tried uploading a ${size} file, which is over the configured upload size limit.`,
);
throw new PayloadTooLargeException();

View File

@ -1,6 +1,6 @@
import type { CanActivate, ExecutionContext } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { config } from '../../config.js';
import { hosts } from '../../config.js';
import { getRequest } from '../../helpers/get-request.js';
@Injectable()
@ -9,11 +9,11 @@ export class HostGuard implements CanActivate {
const request = getRequest(context);
const referer = request.headers.referer;
if (!referer) {
request.host = config.hosts[0];
request.host = hosts[0];
return true;
}
const host = config.hosts.find((host) => host.pattern.test(referer));
const host = hosts.find((host) => host.pattern.test(referer));
if (!host) throw new BadRequestException('Invalid host.');
request.host = host;
return true;

View File

@ -1,7 +1,6 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import normalizeUrl from 'normalize-url';
import type { MicroHost } from '../../classes/MicroHost.js';
import { config } from '../../config.js';
import { hosts, rootHost, type MicroHost } from '../../config.js';
import { randomItem } from '../../helpers/random-item.helper.js';
import type { User } from '../user/user.entity.js';
@ -19,9 +18,9 @@ export class HostService {
* @throws if the host could not be resolved.
*/
getHostFrom(url: string | undefined, tags: string[] | null): MicroHost {
if (!url) return config.rootHost;
if (!url) return rootHost;
const normalised = HostService.normaliseHostUrl(url);
for (const host of config.hosts) {
for (const host of hosts) {
if (!host.pattern.test(normalised)) continue;
if (tags && host.tags) {
const hasTags = host.tags.every((tag) => tags.includes(tag));

View File

@ -1,6 +1,6 @@
import { Entity, ManyToOne, OneToOne, OptionalProps, PrimaryKey, Property, type Ref } from '@mikro-orm/core';
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { config } from '../../config.js';
import { rootHost } from '../../config.js';
import { generateDeleteKey } from '../../helpers/generate-delete-key.helper.js';
import { User } from '../user/user.entity.js';
@ -54,7 +54,7 @@ export class Invite {
@Property({ persist: false })
@Field(() => String)
get url() {
const url = new URL(config.rootHost.url);
const url = new URL(rootHost.url);
url.pathname = this.path;
return url;
}

View File

@ -2,8 +2,8 @@ import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/postgresql';
import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import type { MicroHost } from '../../classes/MicroHost.js';
import { Link } from './link.entity.js';
import type { MicroHost } from '../../config.js';
@Injectable()
export class LinkService {

View File

@ -7,7 +7,7 @@ import dedent from 'dedent';
import handlebars from 'handlebars';
import ms from 'ms';
import { nanoid } from 'nanoid';
import { config } from '../../config.js';
import { config, rootHost } from '../../config.js';
import type { Permission } from '../../constants.js';
import { generateContentId } from '../../helpers/generate-content-id.helper.js';
import { sendMail } from '../../helpers/send-mail.helper.js';
@ -38,7 +38,7 @@ export class UserService {
@InjectRepository(UserVerification) private readonly verificationRepo: EntityRepository<UserVerification>,
@InjectRepository(File) private readonly fileRepo: EntityRepository<File>,
@InjectRepository(Paste) private readonly pasteRepo: EntityRepository<Paste>,
private readonly inviteService: InviteService
private readonly inviteService: InviteService,
) {}
async getUser(id: string, verified: boolean) {
@ -61,7 +61,7 @@ export class UserService {
orderBy: {
createdAt: QueryOrder.DESC,
},
}
},
);
}
@ -76,7 +76,7 @@ export class UserService {
orderBy: {
createdAt: QueryOrder.DESC,
},
}
},
);
}
@ -111,7 +111,7 @@ export class UserService {
});
user.verifications.add(verification);
const verifyUrl = `${config.rootHost.url}/api/user/${verification.user.id}/verify/${verification.id}`;
const verifyUrl = `${rootHost.url}/api/user/${verification.user.id}/verify/${verification.id}`;
const html = UserService.EMAIL_TEMPLATE({ verifyUrl });
await sendMail({
to: user.email,
@ -164,7 +164,7 @@ export class UserService {
$gt: new Date(),
},
},
{ populate: ['user'] }
{ populate: ['user'] },
);
if (!verification) {

View File

@ -159,6 +159,9 @@ importers:
bytes:
specifier: ^3.1.2
version: 3.1.2
chalk:
specifier: ^5.3.0
version: 5.3.0
content-range:
specifier: ^2.0.2
version: 2.0.2
@ -207,6 +210,12 @@ importers:
vitest:
specifier: ^1.1.3
version: 1.1.3(@types/node@20.10.6)
zod:
specifier: ^3.22.4
version: 3.22.4
zod-validation-error:
specifier: ^2.1.0
version: 2.1.0(zod@3.22.4)
packages/web:
dependencies:
@ -4458,6 +4467,11 @@ packages:
ansi-styles: 4.3.0
supports-color: 7.2.0
/chalk@5.3.0:
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
dev: true
/change-case-all@1.0.14:
resolution: {integrity: sha512-CWVm2uT7dmSHdO/z1CXT/n47mWonyypzBbuCy5tN7uMg22BsfkhwT6oHmFCAk+gL1LOOxhdbB9SZz3J1KTY3gA==}
dependencies:
@ -10835,6 +10849,19 @@ packages:
resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==}
dev: false
/zod-validation-error@2.1.0(zod@3.22.4):
resolution: {integrity: sha512-VJh93e2wb4c3tWtGgTa0OF/dTt/zoPCPzXq4V11ZjxmEAFaPi/Zss1xIZdEB5RD8GD00U0/iVXgqkF77RV7pdQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
zod: ^3.18.0
dependencies:
zod: 3.22.4
dev: true
/zod@3.22.4:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
dev: true
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false