How To Make A Simple Captcha Engine Using Google Sheets/Apps Script
Tags: javascript, googlecloud, sheets, appscript
Published: 2021-02-25 17:12:48

As I was finishing up a recent website project I realized that the "message us" form I had set up did not have a captcha. Being that it was also tied directly to a cloud function that immediately sends an email, I just did not feel comfortable leaving it that way.

So, I came up with a simple captcha engine. The captcha works by asking you to provide a simple answer to an additional question. To fool the bots and computers, the numbers are replaced by images of the numbers with some random lines scribbled in the background. You can view the images I'm using [here](https://drive.google.com/drive/folders/1lgrMUaRev3qcm-NqQrM4J7Z7LL5Hzlom?usp=sharing). The captcha box, **simplecaptchamom**, is the main container. It contains five boxes; Box 1 displays number 1, Box 2 the add symbol, Box 3 number 2, Box 4 the equal symbol, and Box 5 is the textarea where the user inputs their answer.

##HTML Code##
```HTML
<div class="simplecaptchamom">
  <div class="captchaitems">
    <img>
  </div>
  <div class="captchaitems"> 
  </div>
  <div class="captchaitems">
    <img>
  </div>
  <div class="captchaitems">  
  </div>
  <textarea class="captchaitems" maxlength="2">
  </textarea>
</div>
```

##CSS Code##
```CSS
body{
font-family: "Quicksand";
display: flex;
flex-flow: column;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: center;
min-height: 1000px;
}

.simplecaptchamom{
display: flex;
position: relative;
width: 169px;
height: 69px;
background-color: white;
color: white;
flex-flow: row;
flex-wrap: nowrap;
justify-content: center;
margin-top: 20%;
align-items: center;
}

.captchaitems {
position: relative;
background-color: white;
color: black;
text-align: center;
font-weight: bold;
font-size: 18px;
width: 33px;
height: 33px;
margin: 3px 3px;
box-sizing: border-box;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
}

.simplecaptchamom textarea{
width: 33px !important;
min-width: 33px !important;
}

.captchaitems img {
display: block;
position: absolute;
width: 100%;
height: 100%;
}

.captchaitems:nth-child(5){
overflow: hidden;
resize: none;
padding: 3px 3px 3px 3px;
box-sizing: border-box;
font-family: inherit;
font-weight: bold;
letter-spacing: .69px;
font-size: 16px !important;
scrollbar-width: none; 
-ms-overflow-style: none;
overflow-y: scroll;
}

textarea::-webkit-scrollbar { display: none;}
```

##JavaScript Code - Client Side##
```Javascript
let cloudObj = {};
let lelink = "https://script.google.com/macros/s/AKfycbyAl44CwyGcvrxb_YWYx0Fd2QKLjThO3WUNNo8Yg3W4P_YJDDEXSr9kOA/exec";
window.onload = () => {
    fetcher({},"first",firstDisp);
};


async function fetcher(data,action,funcAft){
    let temp = await getCaptchaObj(action,data).then(resObj=>{
      funcAft(resObj);
    })
}

async function getCaptchaObj(action,data){ 
    var myRequest = new Request(lelink+"?paraOne="+action);
    data = JSON.stringify(data);
    
    const returnVal = await fetch(myRequest, {
        method: 'POST', // *GET, POST, PUT, DELETE, etc.
        mode: 'cors', // no-cors, *cors, same-origin
        cache: 'default', // *default, no-cache, reload, force-cache, only-if-cached
        credentials: 'omit', // include, *same-origin, omit
        headers: {
            //'Content-Type': 'text/txt'
            // 'Content-Type': 'application/x-www-form-urlencoded',
        },
        redirect: 'follow', // manual, *follow, error
        referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
        body:data // body data type must match "Content-Type" header
     }).then(function(response) {
                if (!response.ok) {  
                    throw new Error("HTTP error, status = " + response.status); 
                 }        
                return response.text();
             }) .then(function(myBlob) {        
                                let cloudObject = JSON.parse(myBlob);        
                                return cloudObject;  
                            }) .catch(function(error) {
                                                 let p = document.createElement('p');
                                                p.appendChild(
                                                    document.createTextNode('Error: ' + error.message)
                                                );
                                                document.querySelectorAll(".simplecaptchamom")[0].innerHTML = p.innerHTML;
                                            });
    return returnVal; 
};

function firstDisp(resObj){
    let captchaItemsCont = document.querySelectorAll(".captchaitems");
    let img1 = captchaItemsCont[0].querySelectorAll("img")[0];
    img1.src = `data:image/jpeg;base64,${resObj.ghh.l11}`;
    let img2 = captchaItemsCont[2].querySelectorAll("img")[0];
    img2.src = `data:image/jpeg;base64,${resObj.ghh.l12}`;

    captchaItemsCont[4].value = "?";
    captchaItemsCont[4].addEventListener("input",checkAnswer);
    
    captchaItemsCont[1].innerHTML = "+";
    captchaItemsCont[3].innerHTML = "=";
    cloudObj = resObj;
}


function checkAnswer(){
    let val = this;
    val.removeEventListener("input",checkAnswer);

    let myTimeOut = window.setTimeout(function(){  
        val = val.value;
        let obj = {};
        obj["one"] = val;
        obj["two"] = cloudObj.ghh.eqid;
        
        if(val.length>=1){
          fetcher(obj,"second",funcToHook);
        }

        window.clearTimeout(myTimeOut);
     },1000);
}

function funcToHook(resObj){
    let mom = document.querySelectorAll(".simplecaptchamom")[0];
    
    if(resObj.status==="pass"){
        mom.innerHTML = "";
        mom.style.backgroundColor = "green";
        mom.style.color = "white";
        mom.innerHTML = "Success!"
  
        let tempyTimy = window.setTimeout(function(){
        window.location.reload();
        window.clearTimeout(tempyTimy);
      },1690);
    }else{
        mom.innerHTML = "";
        mom.style.backgroundColor = "red";
        mom.style.color = "white";
        mom.innerHTML = "Failed!"
 
       let tempyTimy = window.setTimeout(function(){
       window.location.reload();
       window.clearTimeout(tempyTimy);
     },1690);
    }
}
```
When the page finishes loading, the function **fetcher** is called. This function accepts three parameters; parameter one is an object, parameter two is context, and parameter three is a function. Fetcher accepts two contexts, first and second. First for when the page has finished loading and you need a new equation - new images - to complete the captcha. Server-side, when we see first we will return images, when we see second, we'll check the answer and return a text response that says pass or fail based on the user's answer.

##JavaScript Code - Server Side - Apps Script/Google Sheets##
```Javascript
var ss = SpreadsheetApp.getActive();
var timeZone = ss.getSpreadsheetTimeZone();
var timestamp4id = Utilities.formatDate(new Date(), timeZone, "dd-MM-yyyy-HH-mm-ss");
let sheet = ss.getSheetByName("sessions");

function doPost(e){
  let paraOneVal = false;
  let basicGetResponse = false;
  paraOneVal =  e.parameters.paraOne;
  paraOneVal = paraOneVal.toString();

  if(paraOneVal==="first"){
    basicGetResponse = makeCaptchaObj();
  }else if(paraOneVal==="second"){
    basicGetResponse = JSON.parse(e.postData.contents);
    basicGetResponse = checkAnswer(basicGetResponse);       
  }
 
 basicGetResponse = JSON.stringify(basicGetResponse);
 basicGetResponse = ContentService.createTextOutput(basicGetResponse).setMimeType(ContentService.MimeType.JAVASCRIPT);
 return  basicGetResponse;
}

function makeCaptchaObj(){
  let captchaObj = {};
  captchaObj["num1"] = Math.floor(Math.random() * (5 - 1) + 1);
  captchaObj["num2"] = Math.floor(Math.random() * (5 - 1) + 1);
  captchaObj["symbol"] = Math.floor(Math.random() * (1 - 0) + 0);
  captchaObj["ans"] = "0";
  captchaObj["ghh"] = 
  startSession(captchaObj.num1,captchaObj.num2,timestamp4id);
  captchaObj.num1 ="what";
  captchaObj.num2 ="whaty";
  return captchaObj
}

function startSession(num1,num2,timestamp4id){
  let obj ={k:"dd"};
  obj["eqid"] = filldeEq(num1,num2,timestamp4id);
  num1=num1+".jpg";
  num2=num2+".jpg";
  
  var folders = DriveApp.getFoldersByName("captchav1");
  while(folders.hasNext()){
       let folder = folders.next();
       let files1 = folder.getFilesByName(num1);
       let files2 = folder.getFilesByName(num2);

            while (files1.hasNext()) {
               var file = files1.next();
               var blob = file.getBlob();
               obj["l11"] = Utilities.base64Encode(blob.getBytes());
              }

            while (files2.hasNext()) {
               var file = files2.next();
               var blob = file.getBlob();
               obj["l12"] = Utilities.base64Encode(blob.getBytes());          
            }      
     }
      return obj;
};

function filldeEq(num1,num2,timestamp4id){  
  let row = sheet.getLastRow();
  row = row+1;
  sheet.appendRow([timestamp4id+row, num1, num2,"plus",(num1+num2),]);

  return timestamp4id+row;
}

function checkAnswer(basicGetResponse){
let id = basicGetResponse.two;
let answer = basicGetResponse.one;
let objy = {};

let rangeFound = sheet.createTextFinder(id).matchCase(false).findNext();
let rowIndex = 0;

if(rangeFound!=null){
     rowIndex = rangeFound.getRowIndex();
     let cloudansw = sheet.getRange(rowIndex,5).getValue();
         cloudansw = parseInt(cloudansw,10);
         answer = parseInt(answer,10);

         if(cloudansw===answer){
             objy["status"] = "pass";
           }else{
             objy["status"] = "fail";
             }
          }else{
             rowIndex = "Not Found Error!";
             objy["notf"]="ntf";
          }
return objy;
};
```

One thing to note here is, Apps Script can be used independently for this without Google Sheets. I prefer to use them in tandem because Sheets provide an easy way for me to view the data I'm working with and it's not too slow for my use-case.

To set up the server, create a Google Sheet Spreadsheet then click Tools>Script Editor. To set up sing Apps Script directly, go to [this link](https://script.google.com/home) and create a new project.

Another advantage of creating the script via sheets is when you need to access Google Services (Gmail, Forms, Docs, Drive e.t.c), you can easily turn them on in the script editor itself. If you create the script separately, you might have to [transfer it to Google Console](https://developers.google.com/apps-script/guides/cloud-platform-projects#switching_to_a_different_standard_gcp_project) and turn on the services there. 

The script itself contains an inbuilt [**doPost(e)**](https://developers.google.com/apps-script/guides/web) function that acts as the main function. When this function is included and the script is published as a web app, an HTTP POST request sent to the script will run this function. So every time **fetcher** is run, this function is called.

When it is called, it will read our context - first or second - which is passed as a parameter - **paraOne** - in the POST request. If the context is first it will run the function **makeCaptchaObj** which generates two random numbers between 1 and 5. This function then searches our Google Drive for the folder that contains our captcha images. It then checks inside the folder for images corresponding to the generated numbers. It reads these images as blobs and converts them to a [base 64 string](https://en.wikipedia.org/wiki/Base64).

On our spreadsheet, in the sheet "sessions", the makeCaptchaObj function will create a new row adding the generated numbers and their answer. It will then create a unique id for this session.
The unique ID and the base 64 string of the images are then sent back to the client-side.

When the **paraOne** reads second, doPost(e) will read the object passed as data in the body of the POST request. This object contains the answer and unique ID sent back from the client side.

Finally, the function **checkAnswer** will read the unique ID and compare the submitted answer to the answer on the sheet. Then it'll send a pass or fail back to the client-side.

You can test the captcha [here.](https://ismailisimba.github.io/simpleappsscriptcaptcha/index)