Skip to main content

Image Upload and Metadata Extraction with Netlify Functions

7 min read

Older Article

This article was published 5 years ago. Some information may be outdated or no longer applicable.

Jamstack architecture pushes deployments to edge servers. That means fast loads, easy scaling, and better security. But there’s no server-side code execution in this world, so you need another way to run backend logic. Serverless functions fill that gap. Netlify makes deploying them dead simple from within their environment. Here we’ll upload images using Netlify Functions, store them in Cloudinary, and do some “geomagic” with the metadata.

Are you new to serverless functions?

If serverless functions are new territory, there are many resources available on the topic of Jamstack and serverless functions.

Access the code and the app

The app needs an image with Exif GPS metadata (more on this later). If you don’t have one handy, download and use this one.

The full codebase is on GitHub. If you deploy this yourself, don’t forget to add your own environment variables. Here’s a demo:

Getting started

The process is simple. We’ll build a function that accepts an image sent via a form (which we’ll also create) through an HTTP POST request. No framework here. Plain vanilla JavaScript.

Upload form

The form is minimal. Here are the critical bits:

<input type="file" id="uploader" />

Sending and parsing a form via serverless functions can happen using FileReader or FormData. We’ll go with FileReader for one reason: we want to send the data as a base64 encoded string, and FileReader has readAsDataURL built in.

Since FileReader doesn’t use promises natively, we can wrap it in a new Promise:

const readFile = (file) => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      resolve(reader.result);
    };
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
};

The readFile function takes a file and hands back the base64 encoded URL once FileReader finishes. But how do we pass a file to this function?

Easy. We’ve already got a file selector, so we read the selected file like this:

const uploader = document.getElementById('uploader');
const file = await readFile(uploader.files[0]);

<input type="file" /> selects a single file. Add the multiple attribute for multiple files. To accept only JPEG and PNG, use: <input type="file" accept="image/png, image/jpeg" />.

We can select a file now, but we can’t process it yet. That’s where the serverless function comes in.

Creating the Netlify Function

The function’s job is to accept the base64 encoded string (our image) and stash it somewhere. You could use S3, but we’re uploading to Cloudinary because we’ll be pulling in some extra features from them later.

This article won’t cover all the Netlify Functions basics, but at a high level, the function signature looks like this:

module.exports.handler = async (event, context) => {
  return {
    statusCode: 200,
    body: 'RETURN MESSAGE',
  };
};

I often see developers using both async and (event, context, callback) as parameters. That’s redundant. The callback is only needed without async. Stick with the async version.

We know we’re receiving a base64 encoded string. Parsing it is easy: event.body contains the payload from the form (we’ll cover the sending part in a moment).

For the upload, we’re using Cloudinary’s Node.js SDK. Configure it with your API keys and secrets, then upload:

const body = event.body;
try {
  const upload = await cloudinary.uploader.upload(body, {
    public_id: 'netlify-uploaded-image',
    image_metadata: true,
  });
}

Note the image_metadata: true option. That’s going to matter a lot shortly.

At this point, the function is ready. Test it locally with netlify dev. It’ll be available at localhost:8888/.netlify/functions/upload. Use Insomnia or Postman for testing.

Updating the form

Now that we know the function’s URL, we can wire up the HTML form:

const upload = async () => {
  // ... the code from the previous part
  const response = await fetch(
    `${document.location.origin}/.netlify/functions/upload`,
    {
      method: 'POST',
      body: file,
    }
  );
  const data = await response.json();
};

document.location.origin is a trick to make sure the form hits the right path after deployment, instead of hardcoding localhost:8888 (which would break in production).

File upload is working. But let’s keep going.

Exif metadata

Remember that image_metadata: true flag? It preserves all the metadata found in the photo, and those values get stored in Cloudinary. With that option set, the upload response also contains all the Exif metadata.

Exif stands for “Exchangeable image file format”. All sorts of metadata gets attached to images: camera type, exposure time, device orientation, GPS location. That metadata survives base64 encoding.

Reading and parsing Geodata

We’ll read the GPS location from the Exif metadata and feed it to Google Maps’ Reverse Geocoding API. First, we grab the GPS Latitude and Longitude, then convert from degrees, minutes, and seconds (the default Exif format) to decimal values. Then we send those decimals to the Reverse Geocoding API to get the location name.

The conversion formula: degrees + (minutes/60) + (seconds/3600). (South and West directions produce negative decimals.)

Here’s the conversion function. The return statement is shaped so it feeds directly into the Reverse Geocoding API:

const convert = (lat, lng) => {
  const latElements = lat.split(' ');
  let = decimalLat = (
    parseInt(latElements[0]) +
    parseInt(latElements[2]) / 60 +
    parseFloat(latElements[3]) / 3600
  ).toFixed(4);
  if (latElements.pop() === 'S') {
    decimalLat = decimalLat * -1;
  }

  const lngElements = lng.split(' ');
  let decimalLng = (
    parseInt(lngElements[0]) +
    parseInt(lngElements[2]) / 60 +
    parseFloat(lngElements[3]) / 3600
  ).toFixed(4);
  if (lngElements.pop() === 'W') {
    decimalLng = decimalLng * -1;
  }

  return `${decimalLat}, ${decimalLng}`;

Here’s how it plugs into the Netlify function:

const lat = upload.image_metadata.GPSLatitude;
const lng = upload.image_metadata.GPSLongitude;
if (!lat || !lng) {
  return {
    statusCode: 400,
    body: JSON.stringify({
      message:
        'Image does not contain GPS EXIF metadata. Please use an image with appropriate metadata',
      error: true,
    }),
  };
}
const latlng = convert(lat, lng);
const response = await (
  await fetch(
    `https://maps.googleapis.com/maps/api/geocode/json?latlng=${latlng}&key=${googleMapKey}`
  )
).json();
let location = response.plus_code.compound_code;

If the image carries GPS Exif metadata, Google hands back the location name.

Location as Text Overlay

Now that we’ve got the location, why not stamp it onto the image itself? That gives us a tidy automated workflow: upload an image, everything else happens automatically. Cloudinary handles this too. The Node.js SDK lets you apply overlays and generate a final image URL. Still inside our Netlify function:

const finalImage = cloudinary.url(upload.public_id, {
  fetch_format: 'auto',
  quality: 'auto',
  overlay: {
    font_family: 'Roboto',
    font_size: 24,
    font_weight: 'bold',
    text: `Location: ${location}`,
  },
  color: colour,
  transformation: [
    {
      radius: 25,
      width: 800,
      crop: 'fit',
    },
  ],
});

This generates a URL you can drop straight into an <img> element’s src attribute. It also optimises the photo and adds the location as an overlay.

Try comparing the file you upload with the file that comes back. If you use the provided example image in Chrome, the difference should be around 300 kB.

Play around with text placement by checking the Cloudinary documentation on overlay positioning.

Experimental feature

When adding a text overlay, you need to pick a text colour. I found one approach that works well and is easy to implement (though it won’t cover every edge case). Cloudinary analyses uploaded images and returns an array of prominent colours: a 2D array with colour names and their frequency. I built some basic logic that reads these colours and compares them against a list of “dark” colours. If there’s a match (based on an arbitrary filter), the text colour flips from black to white:

const colours = await cloudinary.api.resource(upload.public_id, {
  colors: true,
});
const predominantColours = colours.predominant.google;
const prominentColours = predominantColours
  .filter((colour) => colour[1] > 35)
  .map((colour) => colour[0]);
const darkColours = ['black', 'brown', 'blue', 'red', 'orange'];
const foundDarkColours = prominentColours.some((colours) =>
  darkColours.includes(colours)
);
let colour = 'black';
if (foundDarkColours) {
  colour = 'white';
}

This worked for all my test cases, but it’s not bulletproof. More research would be needed, possibly combining it with other colour analysis techniques.

Conclusion

Netlify functions (and serverless functions generally) open up a wide range of possibilities for server-side code execution. You can do something as small as calling a third-party SDK, or, as we’ve seen here, chain multiple SDK calls together to build a complete workflow. Works brilliantly for Jamstack, but suits other architectures just as well.