mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-11 07:00:41 -04:00
feat(client): validate ms learn submissions (#51008)
Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
7d0fc4d744
commit
ae164d7ca8
@@ -682,6 +682,7 @@
|
||||
"http-url": "An unsecure (http) URL cannot be used.",
|
||||
"own-work-url": "Remember to submit your own work.",
|
||||
"publicly-visible-url": "Remember to submit a publicly visible app URL.",
|
||||
"ms-learn-link": "Please use a valid Microsoft Learn trophy link.",
|
||||
"path-url": "You probably want to submit the root path i.e. https://example.com, not https://example.com/path"
|
||||
},
|
||||
"certification": {
|
||||
|
||||
@@ -16,13 +16,15 @@ import {
|
||||
composeValidators,
|
||||
fCCValidator,
|
||||
httpValidator,
|
||||
pathValidator
|
||||
pathValidator,
|
||||
microsoftValidator
|
||||
} from './form-validators';
|
||||
|
||||
export type FormOptions = {
|
||||
ignored?: string[];
|
||||
isEditorLinkAllowed?: boolean;
|
||||
isLocalLinkAllowed?: boolean;
|
||||
isMicrosoftLearnLink?: boolean;
|
||||
required?: string[];
|
||||
types?: { [key: string]: string };
|
||||
placeholders?: { [key: string]: string };
|
||||
@@ -42,7 +44,8 @@ function FormFields(props: FormFieldsProps): JSX.Element {
|
||||
required = [],
|
||||
types = {},
|
||||
isEditorLinkAllowed = false,
|
||||
isLocalLinkAllowed = false
|
||||
isLocalLinkAllowed = false,
|
||||
isMicrosoftLearnLink = false
|
||||
} = options;
|
||||
|
||||
const nullOrWarning = (
|
||||
@@ -71,6 +74,7 @@ function FormFields(props: FormFieldsProps): JSX.Element {
|
||||
if (!isLocalLinkAllowed) {
|
||||
validators.push(localhostValidator);
|
||||
}
|
||||
if (isMicrosoftLearnLink) validators.push(microsoftValidator);
|
||||
const validationWarning = composeValidators(...validators)(value);
|
||||
const message: string = (error ||
|
||||
validationError ||
|
||||
|
||||
63
client/src/components/formHelpers/form-validators.test.tsx
Normal file
63
client/src/components/formHelpers/form-validators.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { isMicrosoftLearnLink } from './form-validators';
|
||||
|
||||
const baseUrl =
|
||||
'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy';
|
||||
describe('form-validators', () => {
|
||||
describe('isMicrosoftLearnLink', () => {
|
||||
it('should reject links to domains other than learn.microsoft.com', () => {
|
||||
{
|
||||
expect(isMicrosoftLearnLink('https://lean.microsoft.com')).toBe(false);
|
||||
expect(isMicrosoftLearnLink('https://learn.microsft.com')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject links without a sharingId', () => {
|
||||
expect(isMicrosoftLearnLink(`${baseUrl}?username=moT01`)).toBe(false);
|
||||
|
||||
expect(isMicrosoftLearnLink(`${baseUrl}?username=moT01&sharingId=`)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject links without a username', () => {
|
||||
expect(isMicrosoftLearnLink(`${baseUrl}?sharingId=Whatever`)).toBe(false);
|
||||
expect(isMicrosoftLearnLink(`${baseUrl}?sharingId=123&username=`)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject links without the /training/achievements/ subpath', () => {
|
||||
expect(
|
||||
isMicrosoftLearnLink(
|
||||
'https://learn.microsoft.com/en-us/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B8'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject links with the wrong trophy subpath', () => {
|
||||
// missing .trophy
|
||||
expect(
|
||||
isMicrosoftLearnLink(
|
||||
'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1?username=moT01&sharingId=E2EF453C1F9208B8'
|
||||
)
|
||||
).toBe(false);
|
||||
// no number
|
||||
expect(
|
||||
isMicrosoftLearnLink(
|
||||
'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-a.trophy?username=moT01&sharingId=E2EF453C1F9208B8'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['en-us', 'fr-fr', 'lang-country'])(
|
||||
'should accept links with the %s locale',
|
||||
locale => {
|
||||
expect(
|
||||
isMicrosoftLearnLink(
|
||||
`https://learn.microsoft.com/${locale}/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B8`
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,27 @@ function isPathRoot(urlString: string): boolean {
|
||||
|
||||
type Validator = (value: string) => React.ReactElement | null;
|
||||
|
||||
// example link: https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B8
|
||||
export const isMicrosoftLearnLink = (value: string): boolean => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const correctDomain = url.hostname === 'learn.microsoft.com';
|
||||
const correctPath = !!url.pathname.match(
|
||||
/^\/[^/]+\/training\/achievements\/learn\.wwl\.get-started-c-sharp-part-\d\.trophy$/
|
||||
);
|
||||
const hasSharingId = !!url.searchParams.get('sharingId');
|
||||
const hasUsername = !!url.searchParams.get('username');
|
||||
return correctDomain && correctPath && hasSharingId && hasUsername;
|
||||
};
|
||||
|
||||
export const microsoftValidator: Validator = value =>
|
||||
!isMicrosoftLearnLink(value) ? <Trans>validation.ms-learn-link</Trans> : null;
|
||||
|
||||
export const editorValidator: Validator = value =>
|
||||
editorRegex.test(value) ? <Trans>validation.editor-url</Trans> : null;
|
||||
|
||||
@@ -36,11 +57,10 @@ export const httpValidator: Validator = value =>
|
||||
export const pathValidator: Validator = value =>
|
||||
isPathRoot(value) ? <Trans>validation.path-url</Trans> : null;
|
||||
|
||||
export function composeValidators(...validators: (Validator | null)[]) {
|
||||
export function composeValidators(...validators: Validator[]) {
|
||||
return (value: string): ReturnType<Validator> | null =>
|
||||
validators.reduce(
|
||||
(error: ReturnType<Validator>, validator) =>
|
||||
error ?? (validator ? validator(value) : null),
|
||||
(error: ReturnType<Validator>, validator) => error ?? validator(value),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
editorValidator,
|
||||
composeValidators,
|
||||
fCCValidator,
|
||||
httpValidator
|
||||
httpValidator,
|
||||
microsoftValidator
|
||||
} from './form-validators';
|
||||
import FormFields, { FormOptions } from './form-fields';
|
||||
|
||||
type URLValues = {
|
||||
type FormValues = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
@@ -22,7 +23,7 @@ type ValidationError = {
|
||||
};
|
||||
|
||||
export type ValidatedValues = {
|
||||
values: URLValues;
|
||||
values: FormValues;
|
||||
errors: ValidationError[];
|
||||
invalidValues: (JSX.Element | null)[];
|
||||
};
|
||||
@@ -31,45 +32,55 @@ const normalizeOptions = {
|
||||
stripWWW: false
|
||||
};
|
||||
|
||||
function formatUrlValues(
|
||||
values: URLValues,
|
||||
function validateFormValues(
|
||||
formValues: FormValues,
|
||||
options: FormOptions
|
||||
): ValidatedValues {
|
||||
const { isEditorLinkAllowed, isLocalLinkAllowed, types } = options;
|
||||
const {
|
||||
isEditorLinkAllowed,
|
||||
isLocalLinkAllowed,
|
||||
isMicrosoftLearnLink,
|
||||
types
|
||||
} = options;
|
||||
const validatedValues: ValidatedValues = {
|
||||
values: {},
|
||||
errors: [],
|
||||
invalidValues: []
|
||||
};
|
||||
const urlValues = Object.keys(values).reduce((result, key: string) => {
|
||||
// NOTE: pathValidator is not used here, because it is only used as a
|
||||
// suggestion - should not prevent form submission
|
||||
const validators = [fCCValidator, httpValidator];
|
||||
const isSolutionLink = key !== 'githubLink';
|
||||
if (isSolutionLink && !isEditorLinkAllowed) {
|
||||
validators.push(editorValidator);
|
||||
}
|
||||
if (!isLocalLinkAllowed) {
|
||||
validators.push(localhostValidator);
|
||||
}
|
||||
|
||||
let value: string = values[key];
|
||||
const nullOrWarning = composeValidators(...validators)(value);
|
||||
if (nullOrWarning) {
|
||||
validatedValues.invalidValues.push(nullOrWarning);
|
||||
}
|
||||
if (value && types && types[key] === 'url') {
|
||||
try {
|
||||
value = normalizeUrl(value, normalizeOptions);
|
||||
} catch (err: unknown) {
|
||||
validatedValues.errors.push({
|
||||
error: err as { message?: string },
|
||||
value
|
||||
});
|
||||
const urlValues = Object.entries(formValues).reduce(
|
||||
(result, [key, value]) => {
|
||||
// NOTE: pathValidator is not used here, because it is only used as a
|
||||
// suggestion - should not prevent form submission
|
||||
const validators = [fCCValidator, httpValidator];
|
||||
const isSolutionLink = key !== 'githubLink';
|
||||
if (isSolutionLink && !isEditorLinkAllowed) {
|
||||
validators.push(editorValidator);
|
||||
}
|
||||
}
|
||||
return { ...result, [key]: value };
|
||||
}, {});
|
||||
if (!isLocalLinkAllowed) {
|
||||
validators.push(localhostValidator);
|
||||
}
|
||||
if (isMicrosoftLearnLink) {
|
||||
validators.push(microsoftValidator);
|
||||
}
|
||||
|
||||
const nullOrWarning = composeValidators(...validators)(value);
|
||||
if (nullOrWarning) {
|
||||
validatedValues.invalidValues.push(nullOrWarning);
|
||||
}
|
||||
if (value && types && types[key] === 'url') {
|
||||
try {
|
||||
value = normalizeUrl(value, normalizeOptions);
|
||||
} catch (err: unknown) {
|
||||
validatedValues.errors.push({
|
||||
error: err as { message?: string },
|
||||
value
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ...result, [key]: value };
|
||||
},
|
||||
{}
|
||||
);
|
||||
validatedValues.values = urlValues;
|
||||
return validatedValues;
|
||||
}
|
||||
@@ -81,7 +92,7 @@ export type StrictSolutionFormProps = {
|
||||
id: string;
|
||||
initialValues?: Record<string, unknown>;
|
||||
options: FormOptions;
|
||||
submit: (values: ValidatedValues, ...args: unknown[]) => void;
|
||||
submit: (values: ValidatedValues) => void;
|
||||
};
|
||||
|
||||
export const StrictSolutionForm = ({
|
||||
@@ -96,8 +107,8 @@ export const StrictSolutionForm = ({
|
||||
return (
|
||||
<Form
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values: URLValues, ...args: unknown[]) => {
|
||||
submit(formatUrlValues(values, options), ...args);
|
||||
onSubmit={(values: FormValues) => {
|
||||
submit(validateFormValues(values, options));
|
||||
}}
|
||||
>
|
||||
{({ handleSubmit, pristine, error }) => (
|
||||
|
||||
@@ -64,7 +64,8 @@ export class SolutionForm extends Component<SolutionFormProps> {
|
||||
},
|
||||
required: ['solution'],
|
||||
isEditorLinkAllowed: false,
|
||||
isLocalLinkAllowed: false
|
||||
isLocalLinkAllowed: false,
|
||||
isMicrosoftLearnLink: false
|
||||
};
|
||||
|
||||
let formFields = solutionField;
|
||||
@@ -109,6 +110,7 @@ export class SolutionForm extends Component<SolutionFormProps> {
|
||||
|
||||
case challengeTypes.msTrophyUrl:
|
||||
formFields = msTrophyField;
|
||||
options.isMicrosoftLearnLink = true;
|
||||
solutionLink =
|
||||
solutionLink +
|
||||
'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=you';
|
||||
|
||||
Reference in New Issue
Block a user