@adobe-commerce/recaptcha 1.0.0-alpha1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.js ADDED
@@ -0,0 +1,5 @@
1
+ const baseConfig = require('@adobe/elsie/config/eslint');
2
+
3
+ module.exports = {
4
+ ...baseConfig,
5
+ };
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # ReCaptcha Module
2
+
3
+ ## Purpose
4
+
5
+ This functionality is designed to prevent CAPTCHAs. It provides methods for detecting and bypassing CAPTCHAs, improving user experience and automating interactions with web services.
6
+ `
7
+
8
+ ## Methods
9
+
10
+ ### Private Methods
11
+
12
+ Private methods are used within the class and are not accessible externally.
13
+
14
+ - `_updateBadgePosition(currentForm, config);`
15
+ - Responsible for changing the widget's position if it needs to be placed inline.
16
+ - `_addRecaptchaScript();`
17
+ - Adds a script to the page.
18
+ - `_fetchStoreConfig();`
19
+ - Requests configuration from the backend.
20
+ - `_loadConfig();`
21
+ - Responsible for loading the config from Session Storage.
22
+
23
+ ### Public Methods
24
+
25
+ `import {setEndpoint, setConfig, initReCaptcha, verifyReCaptcha } from "@adobe/recaptcha"`
26
+
27
+ Public methods are available for use when interacting with the functionality.
28
+
29
+ - `setEndpoint(url : string);`
30
+ - It sets the URL from which the reCAPTCHA settings will be fetched.
31
+ - `setConfig(configList : [{ badgeId: 'badgeId'}]);`
32
+ - Initializes the configuration, accepting a URL and a set of parameters. The set of parameters is necessary for customizing form settings. Init on top lvl application.
33
+ - `initReCaptcha();`
34
+ - Initializes reCAPTCHA and adds a script to the website.
35
+ - `verifyReCaptcha();`
36
+ - If the method is present, it returns a token.
37
+
38
+ ## Installation
39
+
40
+ To install this functionality, follow these steps:
41
+
42
+ 1. npm i: `@adobe/recaptcha`
43
+
44
+ 2. [ setEndpoint ] - Use this function at the top level to pass the backend URL.
45
+
46
+ 3. [ setConfig ] - Also use this function at the top level to pass your custom configurations if you plan to use your custom form.
47
+
48
+ 4. [ initReCaptcha ] - Call the function on the page where Dropins is integrated, or immediately after setEndpoint or setConfig. Adds a script to the website.
49
+
50
+ 5. [ verifyReCaptcha ] This function serves as an example in either the API method or your form submission handler. It returns a token, which can then be initialized in the headers upon receipt.
51
+
52
+
53
+ ## Summary
54
+
55
+ This functionality provides methods for preventing and solving CAPTCHAs, enhancing automation and interaction with websites. Using it will help simplify processes related to CAPTCHA.
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@adobe-commerce/recaptcha",
3
+ "version": "1.0.0-alpha1",
4
+ "license": "SEE LICENSE IN LICENSE.md",
5
+ "description": "Module allows to efficiently verify that users are humans, not bots or spammers",
6
+ "engines": {
7
+ "node": ">=16"
8
+ },
9
+ "main": "src/index.ts",
10
+ "scripts": {
11
+ "lint": "eslint",
12
+ "test": "jest",
13
+ "test:ci": "jest --config jest.config.js --passWithNoTests --coverage",
14
+ "build": " "
15
+ },
16
+ "dependencies": {
17
+ "@adobe-commerce/fetch-graphql": "~1.0.0",
18
+ "@adobe/recaptcha": "file:../../packages/recaptcha"
19
+ },
20
+ "devDependencies": {}
21
+ }
@@ -0,0 +1,3 @@
1
+ export * from './message.config';
2
+ export * from './typeForms.config';
3
+ export * from './recaptchaBadgeSelector.config';
@@ -0,0 +1,7 @@
1
+ export const recaptchaMessage = {
2
+ failedFetch: 'Failed to fetch config from backend with status:',
3
+ failedSetStorageConfig: 'Failed to set storage config',
4
+ failedGetStorageConfig: 'Configuration could not be loaded.',
5
+ failedExecutionRecaptcha: 'Recaptcha execution failed',
6
+ failedInitializing: 'An error occurred while initializing ReCaptcha:',
7
+ };
@@ -0,0 +1 @@
1
+ export const recaptchaBadgeSelector = '.grecaptcha-badge iframe';
@@ -0,0 +1,12 @@
1
+ export const typeDefaultForm: Record<string, string> = {
2
+ PLACE_ORDER: 'placeOrder',
3
+ CONTACT: 'contactUs',
4
+ CUSTOMER_LOGIN: 'generateCustomerToken',
5
+ CUSTOMER_FORGOT_PASSWORD: 'requestPasswordResetEmail',
6
+ CUSTOMER_CREATE: 'createCustomerV2',
7
+ CUSTOMER_EDIT: 'updateCustomerV2',
8
+ NEWSLETTER: 'subscribeEmailToNewsletter',
9
+ PRODUCT_REVIEW: 'createProductReview',
10
+ SENDFRIEND: 'SENDFRIEND',
11
+ BRAINTREE: 'BRAINTREE',
12
+ };
@@ -0,0 +1,12 @@
1
+ export const RECAPTCHA_CONFIGURATION_V3 = `query {
2
+ recaptchaV3Config {
3
+ is_enabled
4
+ website_key
5
+ minimum_score
6
+ badge_position
7
+ language_code
8
+ failure_message
9
+ forms
10
+ theme
11
+ }
12
+ }`;
package/src/index.ts ADDED
@@ -0,0 +1,222 @@
1
+ import {
2
+ ReCaptchaV3Response,
3
+ PropsFormTypes,
4
+ ReCaptchaV3Model,
5
+ } from './types/recaptcha.types';
6
+ import { recaptchaMessage, recaptchaBadgeSelector } from './configs';
7
+ import {
8
+ extendConfig,
9
+ setConfigStorage,
10
+ getConfigStorage,
11
+ checkRecaptchaBadge,
12
+ convertKeysToCamelCase,
13
+ } from './lib';
14
+ import {
15
+ getRecaptchaToken,
16
+ verifyReCaptchaLoad,
17
+ } from './services/recaptcha.service';
18
+ import { RECAPTCHA_CONFIGURATION_V3 } from './graphql/recaptchaConfig.graphql';
19
+
20
+ import { FetchGraphQL } from '@adobe/fetch-graphql';
21
+
22
+ export const recaptchaFetchApi = new FetchGraphQL().getMethods();
23
+
24
+ export class RecaptchaModule {
25
+ _enableReCAPTCHA: boolean = false;
26
+ _recaptchaBackendEndpoint: string =
27
+ recaptchaFetchApi.getConfig()?.endpoint || '';
28
+ _recaptchaScriptUrl: string = 'https://www.google.com/recaptcha/api.js';
29
+ _configStorageKey: string = 'recaptchaConfig';
30
+ _logger: boolean = false;
31
+
32
+ async _updateBadgePosition(
33
+ badgeId: string,
34
+ config: ReCaptchaV3Model
35
+ ): Promise<void | null> {
36
+ if (!config) return;
37
+
38
+ if (config?.badgePosition === 'inline') {
39
+ await verifyReCaptchaLoad(badgeId, config, this._logger);
40
+ } else {
41
+ const isBadgeLoaded = await checkRecaptchaBadge();
42
+
43
+ if (!isBadgeLoaded) return;
44
+
45
+ const recaptchaBadge = document.querySelector(
46
+ recaptchaBadgeSelector
47
+ ) as HTMLIFrameElement;
48
+
49
+ const shouldUpdateSrc =
50
+ config.theme &&
51
+ recaptchaBadge &&
52
+ !recaptchaBadge.src.includes('theme=dark') &&
53
+ !recaptchaBadge.src.includes('theme=light');
54
+
55
+ if (shouldUpdateSrc) {
56
+ recaptchaBadge.setAttribute(
57
+ 'src',
58
+ `${recaptchaBadge.src}&theme=${config.theme}`
59
+ );
60
+ }
61
+ }
62
+ }
63
+
64
+ async _addRecaptchaScript(): Promise<void> {
65
+ const config = await this._loadConfig();
66
+
67
+ if (!document.getElementById('recaptchaId') && config) {
68
+ const webApiKey = config.websiteKey;
69
+ const isBadgeGlobal = config.badgePosition === 'inline';
70
+ const languageCode = config.languageCode;
71
+
72
+ if (!webApiKey) return;
73
+
74
+ const script = document.createElement('script');
75
+ script.setAttribute('id', 'recaptchaId');
76
+ script.defer = true;
77
+ script.src = isBadgeGlobal
78
+ ? `${this._recaptchaScriptUrl}?render=${webApiKey}&badge=none&hl=${languageCode}`
79
+ : `${this._recaptchaScriptUrl}?render=${webApiKey}&badge=${config.badgePosition}&hl=${languageCode}`;
80
+
81
+ document.head.appendChild(script);
82
+ }
83
+ }
84
+
85
+ async _fetchStoreConfig(): Promise<ReCaptchaV3Response | undefined> {
86
+ try {
87
+ const response = await recaptchaFetchApi.fetchGraphQl(
88
+ RECAPTCHA_CONFIGURATION_V3,
89
+ {
90
+ method: 'GET',
91
+ cache: 'force-cache',
92
+ }
93
+ );
94
+
95
+ if (response?.errors?.length) {
96
+ this._logger && console.error(response.errors[0].message);
97
+
98
+ return;
99
+ }
100
+
101
+ return response;
102
+ } catch (error) {
103
+ this._logger && console.error(`${recaptchaMessage.failedFetch}:`, error);
104
+ }
105
+ }
106
+
107
+ async _loadConfig(): Promise<ReCaptchaV3Model | null> {
108
+ const config = await getConfigStorage(this._configStorageKey);
109
+
110
+ if (!config) {
111
+ this._logger && console.error(recaptchaMessage.failedGetStorageConfig);
112
+
113
+ return null;
114
+ }
115
+
116
+ this._enableReCAPTCHA = !!config.isEnabled;
117
+
118
+ return config;
119
+ }
120
+
121
+ setEndpoint(url: string) {
122
+ if (!url) return;
123
+
124
+ this._recaptchaBackendEndpoint = url;
125
+ recaptchaFetchApi.setEndpoint(url);
126
+ }
127
+
128
+ async setConfig(configList: PropsFormTypes[]) {
129
+ try {
130
+ const config = await this._fetchStoreConfig();
131
+
132
+ if (!config?.data?.recaptchaV3Config) {
133
+ sessionStorage.removeItem(this._configStorageKey);
134
+ return;
135
+ }
136
+
137
+ const transformConfig: ReCaptchaV3Model = convertKeysToCamelCase(
138
+ config?.data?.recaptchaV3Config
139
+ );
140
+
141
+ const extendedRecaptchaConfig = extendConfig(transformConfig, configList);
142
+
143
+ if (extendedRecaptchaConfig) {
144
+ setConfigStorage(
145
+ this._configStorageKey,
146
+ extendedRecaptchaConfig,
147
+ this._logger
148
+ );
149
+ }
150
+ } catch (error) {
151
+ this._logger &&
152
+ console.error(recaptchaMessage.failedSetStorageConfig, error);
153
+
154
+ sessionStorage.removeItem(this._configStorageKey);
155
+ }
156
+ }
157
+
158
+ async initReCaptcha(lazyLoadTimeout = 3000) {
159
+ // IIFE added to fix SonarQube error "Promise returned in function argument where a void return was expected"
160
+ setTimeout(() => {
161
+ (async () => {
162
+ try {
163
+ const config = await this._loadConfig();
164
+
165
+ if (!config?.forms || !config.isEnabled) {
166
+ return;
167
+ }
168
+
169
+ await this._addRecaptchaScript();
170
+
171
+ if (config.badgePosition === 'inline') {
172
+ await Promise.all(
173
+ (config.forms as PropsFormTypes[]).map((element) =>
174
+ this._updateBadgePosition(element.badgeId, config)
175
+ )
176
+ );
177
+ } else {
178
+ await this._updateBadgePosition('', config);
179
+ }
180
+ } catch (error) {
181
+ this._logger &&
182
+ console.error(recaptchaMessage.failedInitializing, error);
183
+ }
184
+ })();
185
+ }, lazyLoadTimeout);
186
+ }
187
+
188
+ async verifyReCaptcha(): Promise<string | undefined> {
189
+ try {
190
+ const config = await this._loadConfig();
191
+
192
+ if (!config?.forms || !config.websiteKey || !config.isEnabled) {
193
+ return undefined;
194
+ }
195
+
196
+ return await getRecaptchaToken(config.websiteKey);
197
+ } catch (error) {
198
+ this._logger && console.error(error);
199
+ }
200
+ }
201
+
202
+ enableLogger(logger: boolean) {
203
+ this._logger = logger;
204
+ }
205
+
206
+ getMethods() {
207
+ return {
208
+ enableLogger: this.enableLogger.bind(this),
209
+ setEndpoint: this.setEndpoint.bind(this),
210
+ setConfig: this.setConfig.bind(this),
211
+ initReCaptcha: this.initReCaptcha.bind(this),
212
+ verifyReCaptcha: this.verifyReCaptcha.bind(this),
213
+ };
214
+ }
215
+ }
216
+
217
+ const recaptcha = new RecaptchaModule();
218
+
219
+ const { initReCaptcha, verifyReCaptcha, setEndpoint, setConfig, enableLogger } =
220
+ recaptcha.getMethods();
221
+
222
+ export { setEndpoint, setConfig, initReCaptcha, verifyReCaptcha, enableLogger };
@@ -0,0 +1,38 @@
1
+ import { recaptchaBadgeSelector } from '../configs';
2
+
3
+ const waitForElement = (selector: string): Promise<void> => {
4
+ return new Promise((resolve, reject) => {
5
+ try {
6
+ // Check if the element is already in the DOM
7
+ if (document.querySelector(selector)) {
8
+ resolve();
9
+ return;
10
+ }
11
+
12
+ // Create an observer to watch for changes
13
+ const observer = new MutationObserver(() => {
14
+ if (document.querySelector(selector)) {
15
+ resolve();
16
+ observer.disconnect();
17
+ }
18
+ });
19
+
20
+ // Start observing the body for child changes only
21
+ observer.observe(document.body, {
22
+ childList: true,
23
+ subtree: false,
24
+ });
25
+ } catch (error) {
26
+ reject(error);
27
+ }
28
+ });
29
+ };
30
+
31
+ export const checkRecaptchaBadge = async (): Promise<boolean> => {
32
+ try {
33
+ await waitForElement(recaptchaBadgeSelector);
34
+ return true;
35
+ } catch (error) {
36
+ return false;
37
+ }
38
+ };
@@ -0,0 +1,13 @@
1
+ export const convertKeysToCamelCase = (obj: {
2
+ [key: string]: any;
3
+ }): { [key: string]: string | number | boolean } => {
4
+ const camelCaseKey = (key: string): string => {
5
+ return key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
6
+ };
7
+
8
+ return Object.keys(obj).reduce((result, key) => {
9
+ const newKey = camelCaseKey(key);
10
+ result[newKey] = obj[key];
11
+ return result;
12
+ }, {} as { [key: string]: any });
13
+ };
@@ -0,0 +1,20 @@
1
+ import { ReCaptchaV3Model } from '../types/recaptcha.types';
2
+ import { typeDefaultForm } from '../configs/typeForms.config';
3
+
4
+ export const extendConfig = (
5
+ config: ReCaptchaV3Model,
6
+ modifyParams: any[]
7
+ ): ReCaptchaV3Model | undefined => {
8
+ if (config && config.forms) {
9
+ const modifyForm = config.forms.concat(modifyParams).map((el) => {
10
+ if (typeof el !== 'string') return { ...el, enabledBadgePlace: false };
11
+
12
+ return {
13
+ badgeId: typeDefaultForm[el],
14
+ enabledBadgePlace: false,
15
+ };
16
+ });
17
+
18
+ return { ...config, forms: [...new Set(modifyForm)] };
19
+ }
20
+ };
@@ -0,0 +1,37 @@
1
+ import { recaptchaMessage } from '../configs';
2
+ import { ReCaptchaV3Model } from '../types/recaptcha.types';
3
+
4
+ const getConfigStorage = async (
5
+ storageKey: string,
6
+ retries = 1,
7
+ delay = 1000
8
+ ): Promise<ReCaptchaV3Model | null> => {
9
+ const storedConfig = sessionStorage.getItem(storageKey);
10
+
11
+ if (storedConfig !== null) {
12
+ return JSON.parse(storedConfig);
13
+ } else if (retries > 0) {
14
+ await new Promise((resolve) => setTimeout(resolve, delay));
15
+
16
+ return getConfigStorage(storageKey, retries - 1, delay);
17
+ }
18
+
19
+ return null;
20
+ };
21
+
22
+ const setConfigStorage = (
23
+ storageKey: string,
24
+ config: ReCaptchaV3Model,
25
+ logger: boolean
26
+ ) => {
27
+ if (!storageKey || !config.websiteKey) return null;
28
+
29
+ try {
30
+ sessionStorage.setItem(storageKey, JSON.stringify(config));
31
+ } catch (error) {
32
+ logger && console.error(recaptchaMessage.failedSetStorageConfig, error);
33
+ return null;
34
+ }
35
+ };
36
+
37
+ export { getConfigStorage, setConfigStorage };
@@ -0,0 +1,4 @@
1
+ export * from './_extendConfig';
2
+ export * from './_storageConfig';
3
+ export * from './_checkRecaptchaBadge';
4
+ export * from './_convertKeysToCamelCase';
@@ -0,0 +1,80 @@
1
+ import { recaptchaMessage } from '../configs';
2
+ import {
3
+ MutationObserverInit,
4
+ ReCaptchaV3Model,
5
+ } from '../types/recaptcha.types';
6
+ const { failedExecutionRecaptcha } = recaptchaMessage;
7
+
8
+ export const getRecaptchaToken = async (
9
+ websiteKey: string
10
+ ): Promise<string> => {
11
+ if (!(window as any).grecaptcha) {
12
+ return Promise.reject(failedExecutionRecaptcha);
13
+ }
14
+
15
+ try {
16
+ const token = await window.grecaptcha.execute(websiteKey, {
17
+ action: 'click',
18
+ });
19
+
20
+ return token;
21
+ } catch (error) {
22
+ return Promise.reject(`${failedExecutionRecaptcha} : ${error}`);
23
+ }
24
+ };
25
+
26
+ export const waitForReCaptcha = () => {
27
+ return new Promise((resolve) => {
28
+ const observer = new MutationObserver((_, obs) => {
29
+ if (window.grecaptcha) {
30
+ obs.disconnect();
31
+ resolve(true);
32
+ }
33
+ });
34
+
35
+ const observerOptions: MutationObserverInit = {
36
+ childList: true,
37
+ subtree: true,
38
+ attributes: true,
39
+ };
40
+
41
+ observer.observe(document.body, observerOptions);
42
+ });
43
+ };
44
+
45
+ export const verifyReCaptchaLoad = async (
46
+ badgeId: string,
47
+ config: ReCaptchaV3Model,
48
+ logger: boolean
49
+ ): Promise<void> => {
50
+ if (!window.grecaptcha) {
51
+ await waitForReCaptcha();
52
+ }
53
+
54
+ return grecaptcha.ready(() => {
55
+ const badgeContainers = document.querySelectorAll(`#${badgeId}`);
56
+
57
+ if (!badgeContainers.length) return;
58
+
59
+ // Handle the case when multiple instances of the drop-in container rendered on the same page
60
+
61
+ badgeContainers.forEach(
62
+ (element) => (element.id = `${element.id}_${Math.random().toString(36)}`) // NOSONAR
63
+ );
64
+
65
+ badgeContainers.forEach((element) => {
66
+ if (element.innerHTML === '') {
67
+ try {
68
+ grecaptcha.render(element.id, {
69
+ sitekey: config.websiteKey as string,
70
+ badge: config.badgePosition,
71
+ size: 'invisible',
72
+ theme: config.theme ?? 'light',
73
+ });
74
+ } catch (error) {
75
+ logger && console.error(error);
76
+ }
77
+ }
78
+ });
79
+ });
80
+ };
@@ -0,0 +1,24 @@
1
+ declare namespace grecaptcha {
2
+ interface RenderParameters {
3
+ sitekey: string;
4
+ theme?: string;
5
+ size?: string;
6
+ tabindex?: number;
7
+ callback?: (response: string) => void;
8
+ 'expired-callback'?: () => void;
9
+ 'error-callback'?: () => void;
10
+ badge?: string;
11
+ }
12
+
13
+ function ready(callback: () => void): void;
14
+ function execute(
15
+ siteKey: string,
16
+ options: { action: string }
17
+ ): Promise<string>;
18
+ function render(
19
+ container: string | HTMLElement,
20
+ parameters: RenderParameters
21
+ ): string;
22
+ function reset(opt_widget_id?: string): void;
23
+ function getResponse(opt_widget_id?: string): string;
24
+ }
@@ -0,0 +1,50 @@
1
+ export interface ReCaptchaV3InitProps {
2
+ is_enabled?: boolean;
3
+ website_key?: string;
4
+ minimum_score?: number;
5
+ badge_position?: string;
6
+ language_code?: string;
7
+ failure_message?: string;
8
+ theme: string;
9
+ }
10
+
11
+ export interface ReCaptchaV3Props extends ReCaptchaV3InitProps {
12
+ forms?: string[];
13
+ }
14
+
15
+ export interface PropsFormTypes {
16
+ badgeId: string;
17
+ enabledBadgePlace?: boolean;
18
+ }
19
+
20
+ export interface ReCaptchaV3ModifyProps extends ReCaptchaV3InitProps {
21
+ forms?: PropsFormTypes[];
22
+ }
23
+
24
+ export interface ReCaptchaV3Response {
25
+ data?: {
26
+ recaptchaV3Config?: ReCaptchaV3Props | ReCaptchaV3ModifyProps;
27
+ };
28
+ errors?: { message: string }[];
29
+ }
30
+
31
+ export interface ReCaptchaV3Model {
32
+ isEnabled?: boolean;
33
+ websiteKey?: string;
34
+ minimumScore?: number;
35
+ badgePosition?: string;
36
+ languageCode?: string;
37
+ failureMessage?: string;
38
+ theme?: string;
39
+ forms?: PropsFormTypes[] | string[];
40
+ }
41
+
42
+ export interface MutationObserverInit {
43
+ childList?: boolean;
44
+ attributes?: boolean;
45
+ characterData?: boolean;
46
+ subtree?: boolean;
47
+ attributeOldValue?: boolean;
48
+ characterDataOldValue?: boolean;
49
+ attributeFilter?: string[];
50
+ }