Browse Source

Use invisible reCAPTCHA

Pouria Ezzati 8 years ago
parent
commit
d713bca4ca

+ 3 - 5
client/actions/index.js

@@ -9,17 +9,15 @@ const addUrl = payload => ({ type: types.ADD_URL, payload });
 const listUrls = payload => ({ type: types.LIST_URLS, payload });
 const updateUrlList = payload => ({ type: types.UPDATE_URL_LIST, payload });
 const deleteUrl = payload => ({ type: types.DELETE_URL, payload });
-const showShortenerLoading = () => ({ type: types.SHORTENER_LOADING });
+export const showShortenerLoading = () => ({ type: types.SHORTENER_LOADING });
 const showTableLoading = () => ({ type: types.TABLE_LOADING });
 export const setShortenerFormError = payload => ({ type: types.SHORTENER_ERROR, payload });
 
-export const createShortUrl = params => dispatch => {
-  dispatch(showShortenerLoading());
-  return axios
+export const createShortUrl = params => dispatch =>
+  axios
     .post('/api/url/submit', params, { headers: { Authorization: cookie.get('token') } })
     .then(({ data }) => dispatch(addUrl(data)))
     .catch(({ response }) => dispatch(setShortenerFormError(response.data.error)));
-};
 
 export const getUrlsList = params => (dispatch, getState) => {
   if (params) dispatch(updateUrlList(params));

+ 46 - 25
client/components/Footer/Footer.js

@@ -1,11 +1,17 @@
-import React from 'react';
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
 import styled from 'styled-components';
+import ReCaptcha from './ReCaptcha';
+import showRecaptcha from '../../helpers/recaptcha';
 
 const Wrapper = styled.footer`
   width: 100%;
   display: flex;
+  flex-direction: column;
   justify-content: center;
-  padding: 4px 0;
+  align-items: center;
+  padding: 4px 0 ${({ isAuthenticated }) => (isAuthenticated ? '8px' : '24px')};
   background-color: white;
 
   a {
@@ -24,26 +30,41 @@ const Text = styled.p`
   }
 `;
 
-const Footer = () => (
-  <Wrapper>
-    <Text>
-      Made with love by{' '}
-      <a href="//thedevs.network/" title="The Devs">
-        The Devs
-      </a>.{' | '}
-      <a
-        href="https://github.com/thedevs-network/kutt"
-        title="GitHub"
-        target="_blank" // eslint-disable-line react/jsx-no-target-blank
-      >
-        GitHub
-      </a>
-      {' | '}
-      <a href="/terms" title="Terms of Service" target="_blank">
-        Terms of Service
-      </a>.
-    </Text>
-  </Wrapper>
-);
-
-export default Footer;
+class Footer extends Component {
+  componentDidMount() {
+    showRecaptcha();
+  }
+
+  render() {
+    return (
+      <Wrapper isAuthenticated={this.props.isAuthenticated}>
+        {!this.props.isAuthenticated && <ReCaptcha />}
+        <Text>
+          Made with love by{' '}
+          <a href="//thedevs.network/" title="The Devs">
+            The Devs
+          </a>.{' | '}
+          <a
+            href="https://github.com/thedevs-network/kutt"
+            title="GitHub"
+            target="_blank" // eslint-disable-line react/jsx-no-target-blank
+          >
+            GitHub
+          </a>
+          {' | '}
+          <a href="/terms" title="Terms of Service" target="_blank">
+            Terms of Service
+          </a>.
+        </Text>
+      </Wrapper>
+    );
+  }
+}
+
+Footer.propTypes = {
+  isAuthenticated: PropTypes.bool.isRequired,
+};
+
+const mapStateToProps = ({ auth: { isAuthenticated } }) => ({ isAuthenticated });
+
+export default connect(mapStateToProps)(Footer);

+ 6 - 4
client/components/Shortener/ShortenerCaptcha.js → client/components/Footer/ReCaptcha.js

@@ -3,17 +3,19 @@ import styled from 'styled-components';
 import config from '../../config';
 
 const Recaptcha = styled.div`
-  display: block;
-  margin: 32px 0;
+  display: flex;
+  margin: 40px 0 16px;
 `;
 
-const ShortenerCaptcha = () => (
+const ReCaptcha = () => (
   <Recaptcha
     id="g-recaptcha"
     className="g-recaptcha"
     data-sitekey={config.RECAPTCHA_SITE_KEY}
     data-callback="recaptchaCallback"
+    data-size="invisible"
+    data-badge="inline"
   />
 );
 
-export default ShortenerCaptcha;
+export default ReCaptcha;

+ 2 - 26
client/components/Login/Login.js

@@ -11,8 +11,6 @@ import TextInput from '../TextInput';
 import Button from '../Button';
 import Error from '../Error';
 import { loginUser, showAuthError, signupUser, showPageLoading } from '../../actions';
-import showRecaptcha from '../../helpers/recaptcha';
-import config from '../../config';
 
 const Wrapper = styled.div`
   flex: 0 0 auto;
@@ -61,11 +59,6 @@ const ForgetPassLink = styled.a`
   }
 `;
 
-const Recaptcha = styled.div`
-  display: block;
-  margin: 0 0 32px 0;
-`;
-
 class Login extends Component {
   constructor() {
     super();
@@ -75,10 +68,6 @@ class Login extends Component {
     this.goTo = this.goTo.bind(this);
   }
 
-  componentDidMount() {
-    showRecaptcha();
-  }
-
   goTo(e) {
     e.preventDefault();
     const path = e.currentTarget.getAttribute('href');
@@ -92,20 +81,14 @@ class Login extends Component {
     const form = document.getElementById('login-form');
     const { value: email } = form.elements.email;
     const { value: password } = form.elements.password;
-    const { value: reCaptchaToken } = form.elements['g-recaptcha-input'];
     if (!email) return showError('Email address must not be empty.');
     if (!emailValidator.validate(email)) return showError('Email address is not valid.');
     if (password.trim().length < 8) {
       return showError('Password must be at least 8 chars long.');
     }
-    if (!reCaptchaToken) {
-      window.grecaptcha.reset();
-      return showError('reCAPTCHA is not valid. Try again.');
-    }
-    window.grecaptcha.reset();
     return type === 'login'
-      ? this.props.login({ email, password, reCaptchaToken })
-      : this.props.signup({ email, password, reCaptchaToken });
+      ? this.props.login({ email, password })
+      : this.props.signup({ email, password });
   }
 
   loginHandler(e) {
@@ -133,13 +116,6 @@ class Login extends Component {
             <TextInput type="email" name="email" id="email" autoFocus />
             <LoginInputLabel htmlFor="password">Password (min chars: 8)</LoginInputLabel>
             <TextInput type="password" name="password" id="password" />
-            <Recaptcha
-              id="g-recaptcha"
-              className="g-recaptcha"
-              data-sitekey={config.RECAPTCHA_SITE_KEY}
-              data-callback="recaptchaCallback"
-            />
-            <input type="hidden" id="g-recaptcha-input" name="g-recaptcha-input" />
             <ForgetPassLink href="/reset-password" title="Forget password" onClick={this.goTo}>
               Forgot your password?
             </ForgetPassLink>

+ 21 - 17
client/components/Shortener/Shortener.js

@@ -6,9 +6,7 @@ import styled from 'styled-components';
 import ShortenerResult from './ShortenerResult';
 import ShortenerTitle from './ShortenerTitle';
 import ShortenerInput from './ShortenerInput';
-import ShortenerCaptcha from './ShortenerCaptcha';
-import { createShortUrl, setShortenerFormError } from '../../actions';
-import showRecaptcha from '../../helpers/recaptcha';
+import { createShortUrl, setShortenerFormError, showShortenerLoading } from '../../actions';
 import { fadeIn } from '../../helpers/animations';
 
 const Wrapper = styled.div`
@@ -50,10 +48,6 @@ class Shortener extends Component {
     this.copyHandler = this.copyHandler.bind(this);
   }
 
-  componentDidMount() {
-    showRecaptcha();
-  }
-
   shouldComponentUpdate(nextProps, nextState) {
     const { isAuthenticated, shortenerError, shortenerLoading, url: { isShortened } } = this.props;
     return (
@@ -66,27 +60,36 @@ class Shortener extends Component {
   }
 
   handleSubmit(e) {
-    const { isAuthenticated } = this.props;
     e.preventDefault();
+    const { isAuthenticated } = this.props;
+    this.props.showShortenerLoading();
     const shortenerForm = document.getElementById('shortenerform');
     const {
       target: originalUrl,
       customurl: customurlInput,
       password: pwd,
-      'g-recaptcha-input': recaptcha,
     } = shortenerForm.elements;
     const target = originalUrl.value.trim();
     const customurl = customurlInput && customurlInput.value.trim();
     const password = pwd && pwd.value;
-    const reCaptchaToken = !isAuthenticated && recaptcha && recaptcha.value;
-    if (!isAuthenticated && !reCaptchaToken) {
-      window.grecaptcha.reset();
-      return this.props.setShortenerFormError('reCAPTCHA is not valid. Try again.');
-    }
     const options = isAuthenticated && { customurl, password };
     shortenerForm.reset();
-    if (!isAuthenticated && recaptcha) window.grecaptcha.reset();
-    return this.props.createShortUrl({ target, reCaptchaToken, ...options });
+    if (!isAuthenticated) {
+      window.grecaptcha.execute(window.captchaId);
+      const getCaptchaToken = () => {
+        setTimeout(() => {
+          if (window.isCaptchaReady) {
+            const reCaptchaToken = window.grecaptcha.getResponse(window.captchaId);
+            window.isCaptchaReady = false;
+            window.grecaptcha.reset(window.captchaId);
+            return this.props.createShortUrl({ target, reCaptchaToken, ...options });
+          }
+          return getCaptchaToken();
+        }, 200);
+      };
+      return getCaptchaToken();
+    }
+    return this.props.createShortUrl({ target, ...options });
   }
 
   copyHandler() {
@@ -118,7 +121,6 @@ class Shortener extends Component {
           handleSubmit={this.handleSubmit}
           setShortenerFormError={this.props.setShortenerFormError}
         />
-        {!isAuthenticated && <ShortenerCaptcha />}
       </Wrapper>
     );
   }
@@ -130,6 +132,7 @@ Shortener.propTypes = {
   shortenerError: PropTypes.string.isRequired,
   shortenerLoading: PropTypes.bool.isRequired,
   setShortenerFormError: PropTypes.func.isRequired,
+  showShortenerLoading: PropTypes.func.isRequired,
   url: PropTypes.shape({
     isShortened: PropTypes.bool.isRequired,
   }).isRequired,
@@ -150,6 +153,7 @@ const mapStateToProps = ({
 const mapDispatchToProps = dispatch => ({
   createShortUrl: bindActionCreators(createShortUrl, dispatch),
   setShortenerFormError: bindActionCreators(setShortenerFormError, dispatch),
+  showShortenerLoading: bindActionCreators(showShortenerLoading, dispatch),
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(Shortener);

+ 0 - 1
client/components/Shortener/ShortenerInput.js

@@ -58,7 +58,6 @@ const ShortenerInput = ({ isAuthenticated, handleSubmit, setShortenerFormError }
     <Submit onClick={handleSubmit}>
       <Icon src="/images/send.svg" />
     </Submit>
-    <input type="hidden" id="g-recaptcha-input" name="g-recaptcha-input" />
     <Error type="shortener" />
     <ShortenerOptions
       isAuthenticated={isAuthenticated}

+ 1 - 15
client/components/Shortener/ShortenerOptions.js

@@ -1,6 +1,6 @@
 import React, { Component } from 'react';
 import PropTypes from 'prop-types';
-import styled, { css } from 'styled-components';
+import styled from 'styled-components';
 import Checkbox from '../Checkbox';
 import TextInput from '../TextInput';
 import { fadeIn } from '../../helpers/animations';
@@ -15,23 +15,9 @@ const Wrapper = styled.div`
   justify-content: flex-start;
   z-index: 2;
 
-  ${({ isAuthenticated }) =>
-    !isAuthenticated &&
-    css`
-      top: 180px;
-    `};
-
   @media only screen and (max-width: 448px) {
     top: 56px;
   }
-
-  ${({ isAuthenticated }) =>
-    !isAuthenticated &&
-    css`
-      @media only screen and (max-width: 448px) {
-        top: 156px;
-      }
-    `};
 `;
 
 const CheckboxWrapper = styled.div`

+ 1 - 1
client/config.example.js

@@ -1,6 +1,6 @@
 module.exports = {
   /*
-    reCaptcha site key
+    Invisible reCaptcha site key
     Create one in https://www.google.com/recaptcha/intro/
   */
   RECAPTCHA_SITE_KEY: '',

+ 5 - 11
client/helpers/recaptcha.js

@@ -1,17 +1,11 @@
 export default function showRecaptcha() {
   const captcha = document.getElementById('g-recaptcha');
   if (!captcha) return null;
-  window.recaptchaCallback = response => {
-    const captchaInput = document.getElementById('g-recaptcha-input');
-    captchaInput.value = response;
-  };
   if (!window.grecaptcha) {
-    return setTimeout(() => showRecaptcha(captcha), 200);
+    return setTimeout(() => showRecaptcha(), 200);
   }
-  return setTimeout(() => {
-    if ((window, captcha, !captcha.childNodes.length)) {
-      window.grecaptcha.render(captcha);
-    }
-    return null;
-  }, 1000);
+  if (!captcha.childNodes.length) {
+    window.captchaId = window.grecaptcha.render(captcha);
+  }
+  return null;
 }

+ 8 - 1
client/pages/_document.js

@@ -1,3 +1,4 @@
+/* eslint-disable react/no-danger */
 import React from 'react';
 import Document, { Head, Main, NextScript } from 'next/document';
 import { ServerStyleSheet } from 'styled-components';
@@ -52,7 +53,13 @@ class AppDocument extends Document {
 
           {this.props.styleTags}
 
-          <script src="https://www.google.com/recaptcha/api.js?render=explicit" />
+          <script
+            dangerouslySetInnerHTML={{
+              __html: `window.recaptchaCallback = function() { window.isCaptchaReady = true; }`,
+            }}
+          />
+
+          <script src="https://www.google.com/recaptcha/api.js?render=explicit" async defer />
           <script src="/analytics.js" />
         </Head>
         <body style={style}>

+ 1 - 1
server/config.example.js

@@ -13,7 +13,7 @@ module.exports = {
   JWT_SECRET: 'securekey',
 
   /*
-    reCaptcha secret key
+    Invisible reCaptcha secret key
     Create one in https://www.google.com/recaptcha/intro/
   */
   RECAPTCHA_SECRET_KEY: '',

+ 2 - 15
server/server.js

@@ -89,21 +89,8 @@ app.prepare().then(() => {
   );
 
   /* User and authentication */
-  server.post(
-    '/api/auth/signup',
-    validationCriterias,
-    validateBody,
-    catchErrors(auth.recaptcha),
-    catchErrors(auth.signup)
-  );
-  server.post(
-    '/api/auth/login',
-    validationCriterias,
-    validateBody,
-    catchErrors(auth.recaptcha),
-    auth.authLocal,
-    auth.login
-  );
+  server.post('/api/auth/signup', validationCriterias, validateBody, catchErrors(auth.signup));
+  server.post('/api/auth/login', validationCriterias, validateBody, auth.authLocal, auth.login);
   server.post('/api/auth/renew', auth.authJwt, auth.renew);
   server.post('/api/auth/changepassword', auth.authJwt, catchErrors(auth.changePassword));
   server.post('/api/auth/generateapikey', auth.authJwt, catchErrors(auth.generateApiKey));