mirror of
https://github.com/graphql-hive/console
synced 2026-05-23 09:08:34 +00:00
Restrict names to alphanum chars, spaces . , _ - / & and escape HTML in email bodies (#3916)
Co-authored-by: Kamil Kisiela <kamil.kisiela@gmail.com>
This commit is contained in:
parent
eedb6a9b23
commit
a5bdcf632a
6 changed files with 129 additions and 25 deletions
|
|
@ -10,7 +10,7 @@ import { OrganizationAccessScope } from '../../auth/providers/organization-acces
|
|||
import { ProjectAccessScope } from '../../auth/providers/project-access';
|
||||
import { TargetAccessScope } from '../../auth/providers/target-access';
|
||||
import { BillingProvider } from '../../billing/providers/billing.provider';
|
||||
import { Emails } from '../../shared/providers/emails';
|
||||
import { Emails, mjml } from '../../shared/providers/emails';
|
||||
import { Logger } from '../../shared/providers/logger';
|
||||
import type { OrganizationSelector } from '../../shared/providers/storage';
|
||||
import { Storage } from '../../shared/providers/storage';
|
||||
|
|
@ -534,7 +534,7 @@ export class OrganizationManager {
|
|||
email: createHash('sha256').update(invitation.email).digest('hex'),
|
||||
}),
|
||||
email,
|
||||
body: `
|
||||
body: mjml`
|
||||
<mjml>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
|
|
@ -544,7 +544,7 @@ export class OrganizationManager {
|
|||
<mj-text>
|
||||
Someone from <strong>${organization.name}</strong> invited you to join GraphQL Hive.
|
||||
</mj-text>.
|
||||
<mj-button href="${this.appBaseUrl}/join/${invitation.code}">
|
||||
<mj-button href="${mjml.raw(this.appBaseUrl)}/join/${invitation.code}">
|
||||
Accept the invitation
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
|
|
@ -647,7 +647,7 @@ export class OrganizationManager {
|
|||
await this.emails.schedule({
|
||||
email: member.user.email,
|
||||
subject: `Organization transfer from ${currentUser.displayName} (${organization.name})`,
|
||||
body: `
|
||||
body: mjml`
|
||||
<mjml>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { NameModel } from '../../shared/entities';
|
||||
import { createConnection } from '../../shared/schema';
|
||||
import { AuthManager } from '../auth/providers/auth-manager';
|
||||
import {
|
||||
|
|
@ -14,7 +15,7 @@ import { Logger } from '../shared/providers/logger';
|
|||
import type { OrganizationModule } from './__generated__/types';
|
||||
import { OrganizationManager } from './providers/organization-manager';
|
||||
|
||||
const OrganizationNameModel = z.string().min(2).max(50);
|
||||
const OrganizationNameModel = NameModel.min(2).max(50);
|
||||
|
||||
const createOrUpdateMemberRoleInputSchema = z.object({
|
||||
name: z
|
||||
|
|
@ -89,18 +90,13 @@ export const resolvers: OrganizationModule.Resolvers = {
|
|||
},
|
||||
Mutation: {
|
||||
async createOrganization(_, { input }, { injector }) {
|
||||
const CreateOrganizationModel = z.object({
|
||||
name: OrganizationNameModel,
|
||||
});
|
||||
|
||||
const result = CreateOrganizationModel.safeParse(input);
|
||||
|
||||
if (!result.success) {
|
||||
const organizationNameResult = OrganizationNameModel.safeParse(input.name.trim());
|
||||
if (!organizationNameResult.success) {
|
||||
return {
|
||||
error: {
|
||||
message: 'Please check your input.',
|
||||
inputErrors: {
|
||||
name: result.error.formErrors.fieldErrors.name?.[0],
|
||||
name: organizationNameResult.error.formErrors.fieldErrors?.[0]?.[0] ?? null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -161,17 +157,13 @@ export const resolvers: OrganizationModule.Resolvers = {
|
|||
};
|
||||
},
|
||||
async updateOrganizationName(_, { input }, { injector }) {
|
||||
const UpdateOrganizationNameModel = z.object({
|
||||
name: OrganizationNameModel,
|
||||
});
|
||||
|
||||
const result = UpdateOrganizationNameModel.safeParse(input);
|
||||
const result = OrganizationNameModel.safeParse(input.name?.trim());
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
error: {
|
||||
message:
|
||||
result.error.formErrors.fieldErrors.name?.[0] ??
|
||||
result.error.formErrors.fieldErrors?.[0]?.[0] ??
|
||||
'Changing the organization name failed.',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from 'zod';
|
||||
import { ProjectType } from '../../shared/entities';
|
||||
import { NameModel, ProjectType } from '../../shared/entities';
|
||||
import { createConnection } from '../../shared/schema';
|
||||
import { OrganizationManager } from '../organization/providers/organization-manager';
|
||||
import { IdTranslator } from '../shared/providers/id-translator';
|
||||
|
|
@ -7,7 +7,7 @@ import { TargetManager } from '../target/providers/target-manager';
|
|||
import type { ProjectModule } from './__generated__/types';
|
||||
import { ProjectManager } from './providers/project-manager';
|
||||
|
||||
const ProjectNameModel = z.string().min(2).max(40);
|
||||
const ProjectNameModel = NameModel.min(2).max(40);
|
||||
const URLModel = z.string().url().max(500);
|
||||
const MaybeModel = <T extends z.ZodType>(value: T) => z.union([z.null(), z.undefined(), value]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Inject, Injectable, InjectionToken, Optional } from 'graphql-modules';
|
||||
import type { EmailsApi, EmailsApiInput } from '@hive/emails';
|
||||
import type { EmailsApi } from '@hive/emails';
|
||||
import { createTRPCProxyClient, httpLink } from '@trpc/client';
|
||||
import { fetch } from '@whatwg-node/fetch';
|
||||
|
||||
|
|
@ -22,11 +22,115 @@ export class Emails {
|
|||
: null;
|
||||
}
|
||||
|
||||
schedule(input: EmailsApiInput['schedule']) {
|
||||
schedule(input: { id?: string; email: string; subject: string; body: MJMLValue }) {
|
||||
if (!this.api) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return this.api.schedule.mutate(input);
|
||||
return this.api.schedule.mutate({
|
||||
...input,
|
||||
body: input.body.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
export type MJMLValue = {
|
||||
readonly kind: 'mjml';
|
||||
readonly content: string;
|
||||
};
|
||||
|
||||
type RawValue = {
|
||||
readonly kind: 'raw';
|
||||
readonly content: string;
|
||||
};
|
||||
type SpecialValues = RawValue;
|
||||
type ValueExpression = string | SpecialValues;
|
||||
|
||||
function isOfKind<T extends SpecialValues>(value: unknown, kind: T['kind']): value is T {
|
||||
return !!value && typeof value === 'object' && 'kind' in value && value.kind === kind;
|
||||
}
|
||||
|
||||
function isRawValue(value: unknown): value is RawValue {
|
||||
return isOfKind<RawValue>(value, 'raw');
|
||||
}
|
||||
|
||||
export function mjml(parts: TemplateStringsArray, ...values: ValueExpression[]): MJMLValue {
|
||||
let content = '';
|
||||
let index = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
const token = values[index++];
|
||||
|
||||
content += part;
|
||||
|
||||
if (index >= parts.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === undefined) {
|
||||
throw new Error('MJML tag cannot be bound an undefined value.');
|
||||
} else if (isRawValue(token)) {
|
||||
content += token.content;
|
||||
} else if (typeof token === 'string') {
|
||||
content += escapeHtml(token);
|
||||
} else {
|
||||
throw new TypeError('mjml: Unexpected value expression.');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'mjml',
|
||||
content: content,
|
||||
};
|
||||
}
|
||||
|
||||
mjml.raw = (content: string): RawValue => ({
|
||||
kind: 'raw',
|
||||
content,
|
||||
});
|
||||
|
||||
/**
|
||||
* @source https://github.com/component/escape-html
|
||||
*/
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
const matchHtmlRegExp = /["'<>]/;
|
||||
const match = matchHtmlRegExp.exec(input);
|
||||
|
||||
if (!match) {
|
||||
return input;
|
||||
}
|
||||
|
||||
let escape;
|
||||
let html = '';
|
||||
let index = 0;
|
||||
let lastIndex = 0;
|
||||
|
||||
for (index = match.index; index < input.length; index++) {
|
||||
switch (input.charCodeAt(index)) {
|
||||
case 34: // "
|
||||
escape = '"';
|
||||
break;
|
||||
break;
|
||||
case 39: // '
|
||||
escape = ''';
|
||||
break;
|
||||
case 60: // <
|
||||
escape = '<';
|
||||
break;
|
||||
case 62: // >
|
||||
escape = '>';
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastIndex !== index) {
|
||||
html += input.substring(lastIndex, index);
|
||||
}
|
||||
|
||||
lastIndex = index + 1;
|
||||
html += escape;
|
||||
}
|
||||
|
||||
return lastIndex !== index ? html + input.substring(lastIndex, index) : html;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from 'zod';
|
||||
import { NameModel } from '../../shared/entities';
|
||||
import { createConnection } from '../../shared/schema';
|
||||
import { OrganizationManager } from '../organization/providers/organization-manager';
|
||||
import { ProjectManager } from '../project/providers/project-manager';
|
||||
|
|
@ -6,7 +7,7 @@ import { IdTranslator } from '../shared/providers/id-translator';
|
|||
import type { TargetModule } from './__generated__/types';
|
||||
import { TargetManager } from './providers/target-manager';
|
||||
|
||||
const TargetNameModel = z.string().min(2).max(30);
|
||||
const TargetNameModel = NameModel.min(2).max(30);
|
||||
const PercentageModel = z.number().min(0).max(100);
|
||||
|
||||
export const resolvers: TargetModule.Resolvers = {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ import type {
|
|||
} from '../__generated__/types';
|
||||
import { parseGraphQLSource, sortDocumentNode } from './schema';
|
||||
|
||||
export const NameModel = z
|
||||
.string()
|
||||
.regex(
|
||||
/^([a-z]|[0-9]|\s|\.|,|_|-|\/|&)+$/i,
|
||||
`Name restricted to alphanumerical characters, spaces and . , _ - / &`,
|
||||
);
|
||||
|
||||
export const SingleSchemaModel = z
|
||||
.object({
|
||||
kind: z.literal('single'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue