diff options
| author | fja-odoo <fja@odoo.com> | 2020-04-24 10:50:28 +0000 |
|---|---|---|
| committer | fja-odoo <fja@odoo.com> | 2020-06-05 10:58:50 +0000 |
| commit | 541148613451f4c14a299672d91c4397d8a9f6de (patch) | |
| tree | c798cd359c3960d4ae86ebc9847666a3a30bf4a6 /addons | |
| parent | fea1706f275e88a0d28dd2776c6aa0dd62c1bf3d (diff) | |
[IMP] google_recaptcha, *: integrate recaptchaV3
* = base_setup, website_form, website_sale, website_crm,
crm_iap_lead_website, website_hr_recruitment, website_mass_mailing
Integrate reCaptchaV3 on website_form submit and website_mass_mailing
subscription.
You can now use ReCaptchaV3 to add reCaptcha verification in any module
using google_recaptcha.
Also added a better error management on the form with custom messages.
task-2217980
closes odoo/odoo#48466
Related: odoo/enterprise#9649
Signed-off-by: Jérémy Kersten (jke) <jke@openerp.com>
Diffstat (limited to 'addons')
27 files changed, 468 insertions, 51 deletions
diff --git a/addons/base_setup/models/res_config_settings.py b/addons/base_setup/models/res_config_settings.py index 410d5130b0b..2248f46bfb1 100644 --- a/addons/base_setup/models/res_config_settings.py +++ b/addons/base_setup/models/res_config_settings.py @@ -30,6 +30,7 @@ class ResConfigSettings(models.TransientModel): module_web_unsplash = fields.Boolean("Unsplash Image Library") module_partner_autocomplete = fields.Boolean("Partner Autocomplete") module_base_geolocalize = fields.Boolean("GeoLocalize") + module_google_recaptcha = fields.Boolean("reCAPTCHA: Easy on Humans, Hard on Bots") report_footer = fields.Text(related="company_id.report_footer", string='Custom Report Footer', help="Footer text displayed at the bottom of all reports.", readonly=False) group_multi_currency = fields.Boolean(string='Multi-Currencies', implied_group='base.group_multi_currency', diff --git a/addons/base_setup/views/res_config_settings_views.xml b/addons/base_setup/views/res_config_settings_views.xml index 1ddab7646ff..07f41954c03 100644 --- a/addons/base_setup/views/res_config_settings_views.xml +++ b/addons/base_setup/views/res_config_settings_views.xml @@ -354,6 +354,20 @@ </div> </div> </div> + <div class="col-12 col-lg-6 o_setting_box" id="recaptcha"> + <div class="o_setting_left_pane"> + <field name="module_google_recaptcha"/> + </div> + <div class="o_setting_right_pane" id="website_recaptcha_settings"> + <label for="module_google_recaptcha"/> + <div class="text-muted"> + reCAPTCHA is a free service that protects your website from spam and abuse. + </div> + <div class="content-group" attrs="{'invisible': [('module_google_recaptcha', '=', False)]}" id="recaptcha_warning"> + <div class="mt16 text-warning"><strong>Save</strong> this page and come back here to set up reCaptcha.</div> + </div> + </div> + </div> </div> <widget name='res_config_dev_tool'/> <div id='about'> diff --git a/addons/crm_iap_lead_website/controllers/main.py b/addons/crm_iap_lead_website/controllers/main.py index e824a57bbef..4a9b63573bf 100644 --- a/addons/crm_iap_lead_website/controllers/main.py +++ b/addons/crm_iap_lead_website/controllers/main.py @@ -8,12 +8,11 @@ from odoo.http import request class ContactController(WebsiteForm): - @http.route('/website_form/<string:model_name>', type='http', auth="public", methods=['POST'], website=True) - def website_form(self, model_name, **kwargs): + def _handle_website_form(self, model_name, **kwargs): if model_name == 'crm.lead': # Add the ip_address to the request in order to add this to the lead # that will be created. With this, we avoid to create a lead from # reveal if a lead is already created from the contact form. request.params['reveal_ip'] = request.httprequest.remote_addr - return super(ContactController, self).website_form(model_name, **kwargs) + return super(ContactController, self)._handle_website_form(model_name, **kwargs) diff --git a/addons/google_recaptcha/__init__.py b/addons/google_recaptcha/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/google_recaptcha/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/google_recaptcha/__manifest__.py b/addons/google_recaptcha/__manifest__.py new file mode 100644 index 00000000000..627313574b3 --- /dev/null +++ b/addons/google_recaptcha/__manifest__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Google reCAPTCHA integration', + 'category': 'Hidden', + 'version': '1.0', + 'description': """ + This module implements reCaptchaV3 so that you can prevent bot spam on your public modules. + """, + 'depends': ['base_setup'], + 'data': [ + 'views/assets.xml', + 'views/res_config_settings_view.xml', + ], + 'auto_install': True, +} diff --git a/addons/google_recaptcha/models/__init__.py b/addons/google_recaptcha/models/__init__.py new file mode 100644 index 00000000000..2d6a3834916 --- /dev/null +++ b/addons/google_recaptcha/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import ir_http +from . import res_config_settings diff --git a/addons/google_recaptcha/models/ir_http.py b/addons/google_recaptcha/models/ir_http.py new file mode 100644 index 00000000000..59dbfd14c96 --- /dev/null +++ b/addons/google_recaptcha/models/ir_http.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging +import requests + +from odoo import api, models, _ +from odoo.http import request +from odoo.exceptions import UserError, ValidationError + +logger = logging.getLogger(__name__) + + +class Http(models.AbstractModel): + _inherit = 'ir.http' + + @api.model + def _verify_request_recaptcha_token(self, action): + """ Verify the recaptcha token for the current request. + If no recaptcha private key is set the recaptcha verification + is considered inactive and this method will return True. + """ + ip_addr = request.httprequest.remote_addr + token = request.params.pop('recaptcha_token_response', False) + recaptcha_result = request.env['ir.http']._verify_recaptcha_token(ip_addr, token, 'website_form') + if recaptcha_result in ['is_human', 'no_secret']: + return True + if recaptcha_result == 'wrong_secret': + raise ValidationError(_("The reCaptcha private key is invalid.")) + elif recaptcha_result == 'wrong_token': + raise ValidationError(_("The reCaptcha token is invalid.")) + elif recaptcha_result == 'timeout': + raise UserError(_("Your request has timed out, please retry.")) + elif recaptcha_result == 'bad_request': + raise UserError(_("The request is invalid or malformed.")) + else: + return False + + @api.model + def _verify_recaptcha_token(self, ip_addr, token, action=False): + """ + Verify a recaptchaV3 token and returns the result as a string. + RecaptchaV3 verify DOC: https://developers.google.com/recaptcha/docs/verify + + :return: The result of the call to the google API: + is_human: The token is valid and the user trustworthy. + is_bot: The user is not trustworthy and most likely a bot. + no_secret: No reCaptcha secret set in settings. + wrong_action: the action performed to obtain the token does not match the one we are verifying. + wrong_token: The token provided is invalid or empty. + wrong_secret: The private key provided in settings is invalid. + timeout: The request has timout or the token provided is too old. + bad_request: The request is invalid or malformed. + :rtype: str + """ + private_key = request.env['ir.config_parameter'].sudo().get_param('recaptcha_private_key') + if not private_key: + return 'no_secret' + min_score = request.env['ir.config_parameter'].sudo().get_param('recaptcha_min_score') + try: + r = requests.post('https://www.recaptcha.net/recaptcha/api/siteverify', { + 'secret': private_key, + 'response': token, + 'remoteip': ip_addr, + }, timeout=2) # it takes ~50ms to retrieve the response + result = r.json() + res_success = result['success'] + res_action = res_success and action and result['action'] + except requests.exceptions.Timeout: + logger.error("Trial captcha verification timeout for ip address %s", ip_addr) + return 'timeout' + except Exception: + logger.error("Trial captcha verification bad request response") + return 'bad_request' + + if res_success: + score = result.get('score', False) + if score < float(min_score): + logger.warning("Trial captcha verification for ip address %s failed with score %f.", ip_addr, score) + return 'is_bot' + if res_action and res_action != action: + logger.warning("Trial captcha verification for ip address %s failed with action %f, expected: %s.", ip_addr, score, action) + return 'wrong_action' + logger.info("Trial captcha verification for ip address %s succeeded with score %f.", ip_addr, score) + return 'is_human' + errors = result.get('error-codes', []) + logger.warning("Trial captcha verification for ip address %s failed error codes %r. token was: [%s]", ip_addr, errors, token) + for error in errors: + if error in ['missing-input-secret', 'invalid-input-secret']: + return 'wrong_secret' + if error in ['missing-input-response', 'invalid-input-response']: + return 'wrong_token' + if error == 'timeout-or-duplicate': + return 'timeout' + if error == 'bad-request': + return 'bad_request' + return 'is_bot' diff --git a/addons/google_recaptcha/models/res_config_settings.py b/addons/google_recaptcha/models/res_config_settings.py new file mode 100644 index 00000000000..2d366ec5cda --- /dev/null +++ b/addons/google_recaptcha/models/res_config_settings.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import requests + +from odoo import _, api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + recaptcha_public_key = fields.Char("Site Key", config_parameter='recaptcha_public_key', groups='base.group_system') + recaptcha_private_key = fields.Char("Secret Key", config_parameter='recaptcha_private_key', groups='base.group_system') + recaptcha_min_score = fields.Float("Minimum score", config_parameter='recaptcha_min_score', groups='base.group_system', default="0.5", help="Should be between 0.0 and 1.0") diff --git a/addons/google_recaptcha/static/src/js/recaptcha.js b/addons/google_recaptcha/static/src/js/recaptcha.js new file mode 100644 index 00000000000..9bab4e7827a --- /dev/null +++ b/addons/google_recaptcha/static/src/js/recaptcha.js @@ -0,0 +1,60 @@ +odoo.define('google_recaptcha.ReCaptchaV3', function (require) { +"use strict"; + +const ajax = require('web.ajax'); +const Class = require('web.Class'); +const core = require('web.core'); + +const _t = core._t; + +const ReCaptcha = Class.extend({ + /** + * @override + */ + init: function () { + this._publicKey = odoo.reCaptchaPublicKey; + }, + /** + * Loads the recaptcha libraries. + * + * @returns {Promise|boolean} promise if libs are loading else false if the reCaptcha key is empty. + */ + loadLibs: function () { + if (this._publicKey) { + this._recaptchaReady = ajax.loadJS(`https://www.recaptcha.net/recaptcha/api.js?render=${this._publicKey}`) + .then(() => new Promise(resolve => window.grecaptcha.ready(() => resolve()))); + return this._recaptchaReady.then(() => !!document.querySelector('.grecaptcha-badge')); + } + return false; + }, + /** + * Returns an object with the token if reCaptcha call succeeds + * If no key is set an object with a message is returned + * If an error occured an object with the error message is returned + * + * @param {string} action + * @returns {Promise|Object} + */ + getToken: async function (action) { + if (!this._publicKey) { + return { + message: _t("No recaptcha public key set."), + }; + } + await this._recaptchaReady; + try { + return { + token: await window.grecaptcha.execute(this._publicKey, {action: action}) + }; + } catch (e) { + return { + error: _t("The recaptcha public key is invalid."), + }; + } + }, +}); + +return { + ReCaptcha: ReCaptcha, +}; +}); diff --git a/addons/google_recaptcha/static/src/scss/recaptcha.scss b/addons/google_recaptcha/static/src/scss/recaptcha.scss new file mode 100644 index 00000000000..1603d4749b7 --- /dev/null +++ b/addons/google_recaptcha/static/src/scss/recaptcha.scss @@ -0,0 +1,5 @@ +// Hide google recaptcha V3 don't forget to add legal +// https://developers.google.com/recaptcha/docs/faq#id-like-to-hide-the-recaptcha-badge.-what-is-allowed +.grecaptcha-badge { + visibility: hidden; +} diff --git a/addons/google_recaptcha/static/src/xml/recaptcha.xml b/addons/google_recaptcha/static/src/xml/recaptcha.xml new file mode 100644 index 00000000000..4b7c23b4c2f --- /dev/null +++ b/addons/google_recaptcha/static/src/xml/recaptcha.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + +<t t-name="google_recaptcha.recaptcha_legal_terms"> + <small class="o_recaptcha_legal_terms"> + Protected by reCAPTCHA, + <a href="https://policies.google.com/privacy" target="_blank">Privacy Policy</a> + & + <a href="https://policies.google.com/terms" target="_blank">Terms of Service</a> + apply. + </small> +</t> + +</odoo> diff --git a/addons/google_recaptcha/views/assets.xml b/addons/google_recaptcha/views/assets.xml new file mode 100644 index 00000000000..d657d0e5c42 --- /dev/null +++ b/addons/google_recaptcha/views/assets.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + +<template id="assets_frontend" inherit_id="web.assets_frontend"> + <xpath expr="//link[last()]" position="after"> + <link rel="stylesheet" type="text/scss" href="/google_recaptcha/static/src/scss/recaptcha.scss"/> + </xpath> + <xpath expr="//script[last()]" position="after"> + <t t-set="recaptcha_public_key" t-value="request.env['ir.config_parameter'].sudo().get_param('recaptcha_public_key')"/> + <script t-if="recaptcha_public_key" type="text/javascript"> + odoo.reCaptchaPublicKey = '<t t-raw="recaptcha_public_key"/>'; + </script> + <script type="text/javascript" src="/google_recaptcha/static/src/js/recaptcha.js"/> + </xpath> +</template> + +</odoo> diff --git a/addons/google_recaptcha/views/res_config_settings_view.xml b/addons/google_recaptcha/views/res_config_settings_view.xml new file mode 100644 index 00000000000..6f5217e72ac --- /dev/null +++ b/addons/google_recaptcha/views/res_config_settings_view.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="res_config_settings_view_form" model="ir.ui.view"> + <field name="name">res.config.settings.view.form.inherit.web.recaptcha</field> + <field name="model">res.config.settings</field> + <field name="inherit_id" ref="base_setup.res_config_settings_view_form"/> + <field name="arch" type="xml"> + <div id="recaptcha_warning" position="replace"> + <div attrs="{'invisible': [('module_google_recaptcha', '=', False)]}"> + <div class="content-group mt16"> + <label for="recaptcha_public_key" class="o_light_label"/> + <field name="recaptcha_public_key"/> + </div> + <div class="content-group"> + <label for="recaptcha_private_key" class="o_light_label"/> + <field name="recaptcha_private_key"/> + </div> + <div class="content-group"> + <label for="recaptcha_min_score" class="o_light_label"/> + <field name="recaptcha_min_score"/> + </div> + <div> + <a href="https://www.google.com/recaptcha/admin/create" class="oe_link" target="_blank"> + <i class="fa fa-arrow-right"/> Generate site/secret keys for v3 reCaptcha type + </a> + </div> + </div> + </div> + </field> + </record> +</odoo> diff --git a/addons/website_crm/controllers/main.py b/addons/website_crm/controllers/main.py index 33687e221fc..0226d3531d9 100644 --- a/addons/website_crm/controllers/main.py +++ b/addons/website_crm/controllers/main.py @@ -18,8 +18,7 @@ class WebsiteForm(WebsiteForm): return ['phone', 'mobile'] # Check and insert values from the form on the model <model> + validation phone fields - @http.route('/website_form/<string:model_name>', type='http', auth="public", methods=['POST'], website=True) - def website_form(self, model_name, **kwargs): + def _handle_website_form(self, model_name, **kwargs): model_record = request.env['ir.model'].sudo().search([('model', '=', model_name), ('website_form_access', '=', True)]) if model_record and hasattr(request.env[model_name], 'phone_format'): try: @@ -46,7 +45,7 @@ class WebsiteForm(WebsiteForm): state = request.env['res.country.state'].search([('code', '=', geoip_state_code), ('country_id.code', '=', geoip_country_code)]) if state: request.params['state_id'] = state.id - return super(WebsiteForm, self).website_form(model_name, **kwargs) + return super(WebsiteForm, self)._handle_website_form(model_name, **kwargs) def insert_record(self, request, model, values, custom, meta=None): is_lead_model = model.model == 'crm.lead' diff --git a/addons/website_form/__manifest__.py b/addons/website_form/__manifest__.py index f1d0c0647df..afb17e1a5a0 100644 --- a/addons/website_form/__manifest__.py +++ b/addons/website_form/__manifest__.py @@ -7,7 +7,7 @@ This module adds a new building block in the website builder in order to build new forms from scratch in any website page. """, 'version': '1.0', - 'depends': ['website', 'mail'], + 'depends': ['website', 'mail', 'google_recaptcha'], 'data': [ 'data/mail_mail_data.xml', 'views/assets.xml', diff --git a/addons/website_form/controllers/main.py b/addons/website_form/controllers/main.py index 218c8189306..0a1cb0f24e8 100644 --- a/addons/website_form/controllers/main.py +++ b/addons/website_form/controllers/main.py @@ -8,11 +8,11 @@ import pytz from datetime import datetime from psycopg2 import IntegrityError -from odoo import http, SUPERUSER_ID +from odoo import http, SUPERUSER_ID, _ from odoo.http import request from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT from odoo.tools.translate import _ -from odoo.exceptions import ValidationError +from odoo.exceptions import ValidationError, UserError from odoo.addons.base.models.ir_qweb_fields import nl2br @@ -21,9 +21,22 @@ class WebsiteForm(http.Controller): # Check and insert values from the form on the model <model> @http.route('/website_form/<string:model_name>', type='http', auth="public", methods=['POST'], website=True) def website_form(self, model_name, **kwargs): + try: + if request.env['ir.http']._verify_request_recaptcha_token('website_form'): + return self._handle_website_form(model_name, **kwargs) + error = _("Suspicious activity detected by Google reCaptcha.") + except (ValidationError, UserError) as e: + error = e.args[0] + return json.dumps({ + 'error': error, + }) + + def _handle_website_form(self, model_name, **kwargs): model_record = request.env['ir.model'].sudo().search([('model', '=', model_name), ('website_form_access', '=', True)]) if not model_record: - return json.dumps(False) + return json.dumps({ + 'error': _("The form's specified model does not exist") + }) try: data = self.extract_data(model_record, request.params) diff --git a/addons/website_form/static/src/snippets/s_website_form/000.js b/addons/website_form/static/src/snippets/s_website_form/000.js index 4d08906c6b5..b06a9b62a74 100644 --- a/addons/website_form/static/src/snippets/s_website_form/000.js +++ b/addons/website_form/static/src/snippets/s_website_form/000.js @@ -3,6 +3,7 @@ odoo.define('website_form.s_website_form', function (require) { var core = require('web.core'); var time = require('web.time'); + const {ReCaptcha} = require('google_recaptcha.ReCaptchaV3'); var ajax = require('web.ajax'); var publicWidget = require('web.public.widget'); @@ -21,11 +22,19 @@ odoo.define('website_form.s_website_form', function (require) { */ init: function () { this._super(...arguments); + this._recaptcha = new ReCaptcha(); this.__started = new Promise(resolve => this.__startResolve = resolve); }, - + willStart: function () { + const res = this._super(...arguments); + if (!this.$target[0].classList.contains('s_website_form_no_recaptcha')) { + this._recaptcha.loadLibs(); + } + return res; + }, start: function () { var self = this; + // Initialize datetimepickers var datepickers_options = { minDate: moment({y: 1900}), @@ -86,7 +95,7 @@ odoo.define('website_form.s_website_form', function (require) { this.$target.parent().find('.s_website_form_end_message').addClass('d-none'); }, - send: function (e) { + send: async function (e) { e.preventDefault(); // Prevent the default submit behavior // Prevent users from crazy clicking this.$target.find('.s_website_form_send, .o_website_form_send').addClass('disabled'); // !compatibility @@ -95,7 +104,7 @@ odoo.define('website_form.s_website_form', function (require) { self.$target.find('#s_website_form_result, #o_website_form_result').empty(); // !compatibility if (!self.check_error_fields({})) { - self.update_status('invalid'); + self.update_status('error', _t("Please fill in the form correctly.")); return false; } @@ -132,6 +141,13 @@ odoo.define('website_form.s_website_form', function (require) { } }); + const tokenObj = await this._recaptcha.getToken('website_form'); + if (tokenObj.token) { + form_values['recaptcha_token_response'] = tokenObj.token; + } else if (tokenObj.error) { + self.update_status('error', tokenObj.error); + return false; + } // Post form and handle result ajax.post(this.$target.attr('action') + (this.$target.data('force_action') || this.$target.data('model_name')), form_values) .then(function (result_data) { @@ -140,7 +156,7 @@ odoo.define('website_form.s_website_form', function (require) { result_data = JSON.parse(result_data); if (!result_data.id) { // Failure, the server didn't return the created record ID - self.update_status('error'); + self.update_status('error', result_data.error ? result_data.error : false); if (result_data.error_fields) { // If the server return a list of bad fields, show these fields for users self.check_error_fields(result_data.error_fields); @@ -265,17 +281,23 @@ odoo.define('website_form.s_website_form', function (require) { return value; }, - update_status: function (status) { + update_status: function (status, message) { if (status !== 'success') { // Restore send button behavior if result is an error this.$target.find('.s_website_form_send, .o_website_form_send').removeClass('disabled'); // !compatibility } var $result = this.$('#s_website_form_result, #o_website_form_result'); // !compatibility + if (status === 'error' && !message) { + message = _t("An error has occured, the form has not been sent."); + } + // Note: we still need to wait that the widget is properly started // before any qweb rendering which depends on xmlDependencies // because the event handlers are binded before the call to // willStart for public widgets... - this.__started.then(() => $result.replaceWith(qweb.render(`website_form.status_${status}`))); + this.__started.then(() => $result.replaceWith(qweb.render(`website_form.status_${status}`, { + message: message, + }))); }, }); }); diff --git a/addons/website_form/static/src/snippets/s_website_form/001.scss b/addons/website_form/static/src/snippets/s_website_form/001.scss index acfaae6f5a1..375a4bb5fe0 100644 --- a/addons/website_form/static/src/snippets/s_website_form/001.scss +++ b/addons/website_form/static/src/snippets/s_website_form/001.scss @@ -43,15 +43,15 @@ padding-bottom: 0.5rem; } - .s_website_form_submit { + .s_website_form_submit, .s_website_form_recaptcha { .s_website_form_label { float: left; height: 1px; } - &.s_website_form_no_submit_label { - .s_website_form_label { - display: none; - } + } + .s_website_form_no_submit_label { + .s_website_form_label { + display: none; } } } diff --git a/addons/website_form/static/src/snippets/s_website_form/options.js b/addons/website_form/static/src/snippets/s_website_form/options.js index f21f3e11ef2..582791f381f 100644 --- a/addons/website_form/static/src/snippets/s_website_form/options.js +++ b/addons/website_form/static/src/snippets/s_website_form/options.js @@ -9,7 +9,10 @@ const qweb = core.qweb; const _t = core._t; const FormEditor = options.Class.extend({ - xmlDependencies: ['/website_form/static/src/xml/website_form_editor.xml'], + xmlDependencies: [ + '/website_form/static/src/xml/website_form_editor.xml', + '/google_recaptcha/static/src/xml/recaptcha.xml', + ], //---------------------------------------------------------------------- // Private @@ -340,8 +343,8 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ const proms = [this._super(...arguments)]; // Disable text edition this.$target.attr('contentEditable', false); - // Make button editable - this.$target.find('.s_website_form_send').attr('contentEditable', true); + // Make button and recaptcha editable + this.$target.find('.s_website_form_send, .s_website_form_recaptcha').attr('contentEditable', true); // Get potential message this.$message = this.$target.parent().find('.s_website_form_end_message'); this.showEndMessage = false; @@ -479,6 +482,22 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ this.$target[0].dataset.mark = value.trim(); this._setLabelsMark(); }, + /** + * Toggle the recaptcha legal terms + */ + toggleRecaptchaLegal: function (previewMode, value, params) { + const recaptchaLegalEl = this.$target[0].querySelector('.s_website_form_recaptcha'); + if (recaptchaLegalEl) { + recaptchaLegalEl.remove(); + } else { + const template = document.createElement('template'); + const labelWidth = this.$target[0].querySelector('.s_website_form_label').style.width; + template.innerHTML = qweb.render("webite_form.s_website_form_recaptcha_legal", {labelWidth: labelWidth}); + const legal = template.content.firstElementChild; + legal.setAttribute('contentEditable', true); + this.$target.find('.s_website_form_submit').before(legal); + } + }, //-------------------------------------------------------------------------- // Private @@ -503,6 +522,8 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ return this.$target[0].dataset.successMode; case 'setMark': return this._getMark(); + case 'toggleRecaptchaLegal': + return !this.$target[0].querySelector('.s_website_form_recaptcha') || ''; } return this._super(...arguments); }, @@ -643,7 +664,7 @@ options.registry.WebsiteFormEditor = FormEditor.extend({ await formInfo.formFields.forEach(async field => { field.formatInfo = formatInfo; await this._fetchFieldRecords(field); - this.$target.find('.s_website_form_submit').before(this._renderField(field)); + this.$target.find('.s_website_form_submit, .s_website_form_recaptcha').first().before(this._renderField(field)); }); } }, @@ -1219,7 +1240,7 @@ options.registry.AddFieldForm = FormEditor.extend({ const field = this._getCustomField('char', 'Custom Text'); field.formatInfo = this._getDefaultFormat(); const htmlField = this._renderField(field); - this.$target.find('.s_website_form_submit').before(htmlField); + this.$target.find('.s_website_form_submit, .s_website_form_recaptcha').first().before(htmlField); this.trigger_up('activate_snippet', { $snippet: $(htmlField), }); diff --git a/addons/website_form/static/src/xml/website_form.xml b/addons/website_form/static/src/xml/website_form.xml index 5403f763e24..9c1390c74df 100644 --- a/addons/website_form/static/src/xml/website_form.xml +++ b/addons/website_form/static/src/xml/website_form.xml @@ -8,17 +8,11 @@ </span> </t> - <!-- Missing field status --> - <t t-name="website_form.status_invalid"> - <span id="s_website_form_result" class="text-danger ml8"> - <i class="fa fa-close mr4" role="img" aria-label="Error" title="Error"/>Please fill in the form correctly. - </span> - </t> - <!-- Error status --> <t t-name="website_form.status_error"> <span id="s_website_form_result" class="text-danger ml8"> - <i class="fa fa-close mr4" role="img" aria-label="Error" title="Error"/>An error has occured, the form has not been sent. + <i class="fa fa-close mr4" role="img" aria-label="Error" title="Error"/> + <t t-esc="message"/> </span> </t> </templates> diff --git a/addons/website_form/static/src/xml/website_form_editor.xml b/addons/website_form/static/src/xml/website_form_editor.xml index 6db59f01a22..13d4ae1aeb5 100644 --- a/addons/website_form/static/src/xml/website_form_editor.xml +++ b/addons/website_form/static/src/xml/website_form_editor.xml @@ -21,6 +21,15 @@ </div> </t> + <t t-name="webite_form.s_website_form_recaptcha_legal"> + <div class="col-12 s_website_form_recaptcha" data-name="Recaptcha Legal"> + <div t-attf-style="width: #{labelWidth or '200px'}" class="s_website_form_label"/> + <div class="col-sm"> + <t t-call="google_recaptcha.recaptcha_legal_terms"/> + </div> + </div> + </t> + <!-- Generic Field Layout --> <!-- Changes made here needs to be reflected in the different Form view (Contact Us, Jobs, ...) --> <t t-name="website_form.field"> diff --git a/addons/website_form/views/snippets/s_website_form.xml b/addons/website_form/views/snippets/s_website_form.xml index 16d10107cf4..8683db21832 100644 --- a/addons/website_form/views/snippets/s_website_form.xml +++ b/addons/website_form/views/snippets/s_website_form.xml @@ -39,6 +39,8 @@ <we-button class="fa fa-fw fa-eye align-self-end toggle-edit-message" title="Edit Message" data-name="message_opt" data-dependencies="show_message_opt"/> </we-row> <we-urlpicker string="URL" data-select-data-attribute="/contactus-thank-you" data-attribute-name="successPage" data-name="url_opt" data-dependencies="show_redirect_opt"/> + <t t-set="recaptcha_public_key" t-value="request.env['ir.config_parameter'].sudo().get_param('recaptcha_public_key')"/> + <we-checkbox t-if="recaptcha_public_key" string="Show reCaptcha Policy" data-toggle-recaptcha-legal="" data-no-preview="true"/> </div> <!-- Add Field Form --> diff --git a/addons/website_mass_mailing/controllers/main.py b/addons/website_mass_mailing/controllers/main.py index dc34504426c..60383155e80 100644 --- a/addons/website_mass_mailing/controllers/main.py +++ b/addons/website_mass_mailing/controllers/main.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import _ from odoo.http import route, request from odoo.osv import expression from odoo.addons.mass_mailing.controllers.main import MassMailController @@ -25,6 +26,11 @@ class MassMailController(MassMailController): @route('/website_mass_mailing/subscribe', type='json', website=True, auth="public") def subscribe(self, list_id, email, **post): + if not request.env['ir.http']._verify_request_recaptcha_token('website_mass_mailing_subscribe'): + return { + 'toast_type': 'danger', + 'toast_content': _("Suspicious activity detected by Google reCaptcha."), + } ContactSubscription = request.env['mailing.contact.subscription'].sudo() Contacts = request.env['mailing.contact'].sudo() name, email = Contacts.get_name_email(email) @@ -41,7 +47,10 @@ class MassMailController(MassMailController): # add email to session request.session['mass_mailing_email'] = email mass_mailing_list = request.env['mailing.list'].sudo().browse(list_id) - return {'toast_content': mass_mailing_list.toast_content} + return { + 'toast_type': 'success', + 'toast_content': mass_mailing_list.toast_content + } @route(['/website_mass_mailing/get_content'], type='json', website=True, auth="public") def get_mass_mailing_content(self, newsletter_id, **post): diff --git a/addons/website_mass_mailing/static/src/js/website_mass_mailing.editor.js b/addons/website_mass_mailing/static/src/js/website_mass_mailing.editor.js index 79e4bb4b6cb..167cd17e9d8 100644 --- a/addons/website_mass_mailing/static/src/js/website_mass_mailing.editor.js +++ b/addons/website_mass_mailing/static/src/js/website_mass_mailing.editor.js @@ -6,6 +6,8 @@ var rpc = require('web.rpc'); var WysiwygMultizone = require('web_editor.wysiwyg.multizone'); var options = require('web_editor.snippets.options'); var wUtils = require('website.utils'); + +const qweb = core.qweb; var _t = core._t; @@ -63,6 +65,39 @@ options.registry.mailing_list_subscribe = options.Class.extend({ }, }); +options.registry.recaptchaSubscribe = options.Class.extend({ + xmlDependencies: ['/google_recaptcha/static/src/xml/recaptcha.xml'], + + /** + * Toggle the recaptcha legal terms + */ + toggleRecaptchaLegal: function (previewMode, value, params) { + const recaptchaLegalEl = this.$target[0].querySelector('.o_recaptcha_legal_terms'); + if (recaptchaLegalEl) { + recaptchaLegalEl.remove(); + } else { + const template = document.createElement('template'); + template.innerHTML = qweb.render("google_recaptcha.recaptcha_legal_terms"); + this.$target[0].appendChild(template.content.firstElementChild); + } + }, + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'toggleRecaptchaLegal': + return !this.$target[0].querySelector('.o_recaptcha_legal_terms') || ''; + } + return this._super(...arguments); + }, +}); + options.registry.newsletter_popup = options.registry.mailing_list_subscribe.extend({ popup_template_id: "editor_new_mailing_list_subscribe_popup", popup_title: _t("Add a Newsletter Subscribe Popup"), diff --git a/addons/website_mass_mailing/static/src/js/website_mass_mailing.js b/addons/website_mass_mailing/static/src/js/website_mass_mailing.js index 1ea942ddc76..2212a707038 100644 --- a/addons/website_mass_mailing/static/src/js/website_mass_mailing.js +++ b/addons/website_mass_mailing/static/src/js/website_mass_mailing.js @@ -6,6 +6,7 @@ var core = require('web.core'); var Dialog = require('web.Dialog'); var utils = require('web.utils'); var publicWidget = require('web.public.widget'); +const {ReCaptcha} = require('google_recaptcha.ReCaptchaV3'); var _t = core._t; @@ -17,6 +18,20 @@ publicWidget.registry.subscribe = publicWidget.Widget.extend({ }, /** + * @constructor + */ + init: function () { + this._super(...arguments); + this._recaptcha = new ReCaptcha(); + }, + /** + * @override + */ + willStart: function () { + this._recaptcha.loadLibs(); + return this._super(...arguments); + }, + /** * @override */ start: function () { @@ -50,7 +65,9 @@ publicWidget.registry.subscribe = publicWidget.Widget.extend({ * @override */ destroy: function () { - this.$target.addClass('d-none'); + if (!this.editableMode) { + this.$target.addClass('d-none'); + } this._super.apply(this, arguments); }, @@ -61,7 +78,7 @@ publicWidget.registry.subscribe = publicWidget.Widget.extend({ /** * @private */ - _onSubscribeClick: function () { + _onSubscribeClick: async function () { var self = this; var $email = this.$(".js_subscribe_email:visible"); @@ -70,22 +87,35 @@ publicWidget.registry.subscribe = publicWidget.Widget.extend({ return false; } this.$target.removeClass('o_has_error').find('.form-control').removeClass('is-invalid'); + const tokenObj = await this._recaptcha.getToken('website_form'); + if (tokenObj.error) { + self.displayNotification({ + type: 'danger', + title: _t("Error"), + message: tokenObj.error, + sticky: true, + }); + return false; + } this._rpc({ route: '/website_mass_mailing/subscribe', params: { 'list_id': this.$target.data('list-id'), 'email': $email.length ? $email.val() : false, + recaptcha_token_response: tokenObj.token, }, }).then(function (result) { - self.$(".js_subscribe_btn").addClass('d-none'); - self.$(".js_subscribed_btn").removeClass('d-none'); - self.$('input.js_subscribe_email').prop('disabled', !!result); - if (self.$popup.length) { - self.$popup.modal('hide'); + if (result.type === 'success') { + self.$(".js_subscribe_btn").addClass('d-none'); + self.$(".js_subscribed_btn").removeClass('d-none'); + self.$('input.js_subscribe_email').prop('disabled', !!result); + if (self.$popup.length) { + self.$popup.modal('hide'); + } } self.displayNotification({ - type: 'success', - title: _t("Success"), + type: result.toast_type, + title: _t(`${result.toast_type === 'success' ? 'Success' : 'Error'}`), message: result.toast_content, sticky: true, }); diff --git a/addons/website_mass_mailing/views/snippets_templates.xml b/addons/website_mass_mailing/views/snippets_templates.xml index da0c52465bf..a8cbc35eba0 100644 --- a/addons/website_mass_mailing/views/snippets_templates.xml +++ b/addons/website_mass_mailing/views/snippets_templates.xml @@ -25,12 +25,14 @@ </template> <template id="s_newsletter_subscribe_form" name="Newsletter"> - <div class="input-group js_subscribe" data-list-id="0"> - <input type="email" name="email" class="js_subscribe_email form-control" placeholder="your email..."/> - <span class="input-group-append"> - <a role="button" href="#" class="btn btn-primary js_subscribe_btn">Subscribe</a> - <a role="button" href="#" class="btn btn-success js_subscribed_btn d-none" disabled="disabled">Thanks</a> - </span> + <div class="s_newsletter_subscribe_form js_subscribe" data-vxml="001" data-list-id="0"> + <div class="input-group"> + <input type="email" name="email" class="js_subscribe_email form-control" placeholder="your email..."/> + <span class="input-group-append"> + <a role="button" href="#" class="btn btn-primary js_subscribe_btn">Subscribe</a> + <a role="button" href="#" class="btn btn-success js_subscribed_btn d-none" disabled="disabled">Thanks</a> + </span> + </div> </div> </template> @@ -66,6 +68,11 @@ t-attf-data-exclude=".o_newsletter_modal #{selector}"> <we-button data-select_mailing_list="" data-no-preview="true">Change Newsletter</we-button> </div> + <div data-js="recaptchaSubscribe" + t-att-data-selector="selector"> + <t t-set="recaptcha_public_key" t-value="request.env['ir.config_parameter'].sudo().get_param('recaptcha_public_key')"/> + <we-checkbox t-if="recaptcha_public_key" string="Show reCaptcha Policy" data-toggle-recaptcha-legal="" data-no-preview="true"/> + </div> <div t-att-data-selector="selector" data-drop-near="p, h1, h2, h3, blockquote, .card"/> <div data-js="newsletter_popup" data-selector=".o_newsletter_popup"> diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 2543be7ceec..fa978efd3a4 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -748,7 +748,7 @@ <div class="col-12 col-xl order-xl-1 oe_cart"> <section class="s_website_form" data-vcss="001" data-snippet="s_website_form"> <div class="container"> - <form action="/website_form/" method="post" enctype="multipart/form-data" class="o_mark_required" data-mark="*" data-force_action="shop.sale.order" data-model_name="sale.order" data-success-mode="redirect" data-success-page="/shop/payment" hide-change-model="true"> + <form action="/website_form/" method="post" enctype="multipart/form-data" class="o_mark_required s_website_form_no_recaptcha" data-mark="*" data-force_action="shop.sale.order" data-model_name="sale.order" data-success-mode="redirect" data-success-page="/shop/payment" hide-change-model="true"> <div class="s_website_form_rows row s_col_no_bgcolor"> <div class="form-group col-12 s_website_form_field" data-type="char" data-name="Field"> <div class="row s_col_no_resize s_col_no_bgcolor"> |
