Handling return values from Azure Functions in Hugo static website

Background

I am using an Azure Function as backend for processing forms submissions from a Hugo static website, to process a simple contact form.

I wanted to add reCAPTCHA support, as the site was generating too many spam emails. I also wanted to show a different confirmation pages depending on whether the reCAPTCHA check passed or failed

There a good few posts about using an Azure Function as backend for a static web site form. But what I could not find was how to handle the return value from the Azure Function.

So as I have a solution, I thought a blog post would be a good idea to share it.

The solution

On my contact form Hugo layout page, I have a hidden iframe, this is used as the target for the HTML form i.e. where the Azure Function posts back too. The Azure function returns a simple string, either "OK" or an error message depending on the outcome of the processing. The hidden target iframe has an onload event handler that checks the return value and redirects the user to a different page depending on the outcome.

 1<script type="text/javascript">var submitted = false;</script>
 2
 3<iframe name="hidden_iframe" id="hidden_iframe" style="display:none;" onload="
 4    if(submitted) {
 5        const res = document.getElementById( 'hidden_iframe' ).contentWindow.document.body.innerText +']';
 6        if (res.includes('OK')) {
 7          window.location='/confirmation/enquiry';
 8        } else {
 9          window.location='/confirmation/error';
10        }
11    }
12    ">
13</iframe>
14
15<form name="contact" action="/api/GenericFormsHandler" method="POST" target="hidden_iframe" onsubmit="submitted=true;">
16
17    <input id="g-recaptcha-response" name="g-recaptcha-response" type="hidden" value="" />
18    <script src="https://www.google.com/recaptcha/api.js?render={{.Site.Data.reCAPCHA.key}}&hl=en"  ></script>
19    <script>
20        if (typeof grecaptcha !== 'undefined') {
21            grecaptcha.ready(function () {
22                grecaptcha.execute('{{.Site.Data.reCAPCHA.key}}', { 'action': 'submit' }).then(function (token) {
23                    document.getElementById('g-recaptcha-response').value = token;
24                });
25            });
26        }
27    </script>
28    <!-- all my input fields -->
29    <input type="submit" id="submitButton" value="Submit" />
30</form>

My Azure Static WebSite is configured to contain a managed Azure Function with an HTTP trigger '/api/GenericFormsHandler' to handle the forms processing.

 1import { AzureFunction, Context, HttpRequest } from "@azure/functions"
 2import fetch from "node-fetch"; // needs to be installed with npm i node-fetch@2.6.1 
 3
 4const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise<void> {
 5    context.log('An HTTP POST trigger function to processed enquiry forms');
 6    var object = {};
 7    var returnValue = "OK";
 8    object["events"] = [];
 9    var status = 200;
10
11    // a very basic HTML form to object parser
12    req.body.split('&').forEach(field => {
13        var pair = field.split("=")
14        object[pair[0]] = decodeURIComponent(pair[1]).replace(/\+/g, " ")
15    }
16
17    context.log("Validating recaptcha token");
18    
19    // the secret key is stored in the Azure Function App settings
20    var postData = `secret=${process.env.RECAPTCHA_SECRETKEY}&response=${object["g-recaptcha-response"]}`
21
22    context.log(`reCAPTCHA request payload: ${JSON.stringify(postData)}`);
23
24    // recaptcha validation only accepts POST requests using 'application/x-www-form-urlencoded' content type
25    const response = await fetch("https://www.google.com/recaptcha/api/siteverify", {
26        method: 'POST',
27        body: postData,
28        headers: {
29            'Content-Type': 'application/x-www-form-urlencoded',
30            'Content-Length': `${JSON.stringify(postData).length}`
31        }
32    });
33
34    if (!response.ok) {
35        context.log("Error calling reCAPTCHA");
36        returnValue = "Error"
37    }
38    else if (response.status >= 400) {
39        context.log('HTTP Error from to reCAPTCHA: ' + response.status + ' - ' + response.statusText);
40        returnValue = "HTTPError"
41    }
42    else {
43        context.log("Successful call to reCAPTCHA");
44        const data = await response.json();
45        context.log(`reCAPTCHA response: ${JSON.stringify(data)}`);
46
47        // if the score is less than the minimum score (App settings) then we don't process the form
48        if (data.success && data.score >= parseFloat(process.env.RECAPTCHA_MINSCORE)) {
49            context.log("Sending email as reCAPTCHA detected a human");
50        
51            const sgMail = require('@sendgrid/mail')
52            sgMail.setApiKey(process.env.SENDGRID_API_KEY)
53            const msg = {
54                // App Settings use for the from and to addresses
55                to: process.env.ENQUIRY_TOADDRESS, 
56                from: process.env.ENQUIRY_FROMADDRESS, 
57                html: // add in the content generate from th form content
58            }
59
60            await sgMail
61                .send(msg)
62                .then((response) => {
63                    context.log(`Send Email returned  ${response[0].statusCode}`)
64                    context.log(response[0].headers)
65                    returnValue = "OK"
66                })
67                .catch((error) => {
68                    context.log(`ERROR sending email ${error}`)
69                    returnValue = error;
70                })
71		
72		} else {
73           context.log("Not sending email as reCAPTCHA detected a bot");
74            returnValue = "reCAPTCHA Error"
75        }
76    }
77
78    context.log(`Setting return value as ${returnValue}`);
79    context.res = {
80        body: returnValue
81    };
82};
83
84export default httpTrigger;

Important

The key thing to note with this solution is that you get the Azure Function to write it's return value into the hidden iFrame on the calling page.

The only reason that this iFrame content (the return value) can read (using JavaScript on the form page) is because the Hugo static pages and managed Azure Function are in the same domain. So there are no cross site scripting issues blocking the reading of the iFrame contents e.g. your get no console error messages in the form:

SecurityError: Blocked a frame with origin "http://www.example.com" from accessing a cross-origin frame.

Hence, the logic on the onload function can pick the correct confirmation page based on the value returned by the Azure Function.

Conclusion

So, not the most elegant solution, but it works.

Hope this post save some other people some time