Skip to content

Commit

Permalink
use zod for config validation
Browse files Browse the repository at this point in the history
  • Loading branch information
sylv committed Jan 7, 2024
1 parent 805ebcd commit b035272
Show file tree
Hide file tree
Showing 20 changed files with 169 additions and 233 deletions.
7 changes: 5 additions & 2 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
82 changes: 0 additions & 82 deletions packages/api/src/classes/MicroConfig.ts

This file was deleted.

21 changes: 0 additions & 21 deletions packages/api/src/classes/MicroConversion.ts

This file was deleted.

9 changes: 0 additions & 9 deletions packages/api/src/classes/MicroEmail.ts

This file was deleted.

45 changes: 0 additions & 45 deletions packages/api/src/classes/MicroHost.ts

This file was deleted.

14 changes: 0 additions & 14 deletions packages/api/src/classes/MicroPurge.ts

This file was deleted.

104 changes: 92 additions & 12 deletions packages/api/src/config.ts
Original file line number Diff line number Diff line change
@@ -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)}
`,
);
}
16 changes: 8 additions & 8 deletions packages/api/src/helpers/resource.entity-base.ts
Original file line number Diff line number Diff line change
@@ -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 })
Expand All @@ -31,18 +31,18 @@ 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() {
const owner = this.owner?.getEntity();
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);
}

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/helpers/send-mail.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand Down
8 changes: 4 additions & 4 deletions packages/api/src/modules/app.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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));
Expand Down
Loading

0 comments on commit b035272

Please sign in to comment.