01.16.18

How I built an image proxy server to anonymise images in twenty minutes

Let’s say you want to anonymise images by hiding any detected face, just by tweaking the srcattribute of the <img> tag in HTML.

If we build a proxy server that does the work for us, we can prepend that server’s endpoint to any image and have them anonymised on their way to the browser, without touching (or owning) the source image. We could even write a jQuery plugin that auto-anonymised all images by tweaking the src attribute on page load.

Design

Imagine this diagram looks all professional and that:

An anonymising proxy would download any image, use Facebox to find the faces, and anonymise the image before serving the modified image to the browser

Our proxy will download any image from the internet, anonymise it, and serve the new image to the browser.

We are going to use Facebox by Machine Box to detect the faces, and while it’s easy to make HTTP requests in Go, I am going to use the Machine Box Go SDKto make things even easier to read.

The nice thing about using Facebox is that, with very little extra work, we could actually teach it the faces that we want to leave in the picture.

I timed myself writing this solution and it came to just under twenty minutes, but I must admit that I start by stealing this code from my past self:

Given an image, and a list of detected faces, it creates a new image and puts black squares wherever a face appears. This is the essence of the service, and it’s only 22 lines of code.

Get Facebox running (1 minute)

I wasted a whole minute getting Facebox running by typing the following into my terminal:

docker run -p 8080:8080 -e "MB_KEY=$MB_KEY" machinebox/facebox

Frustrated by this abhorrent waste of time, I was keen to get started on the tool.

If you haven’t setup your MB_KEY environment variable, this might take you another three or four minutes if you can bear it. You can get a free key from the Account page on the Machine Box website.

The server code (5 minutes)

I created a new folder called anonproxy, and added a main.go file containing this setup stuff:

func main() {
  var (
      addr = flag.String("addr", "localhost:8000", "Listen address")
      faceboxAddr = flag.String("facebox", "http://localhost:8080", "Facebox address")
  )
  flag.Parse()
  client := &http.Client{Timeout: 10 * time.Second}
  fb := facebox.New(*faceboxAddr)

This just sets some flags for my program, including the address on which my proxy will run (localhost:8000) and where Facebox is running (http://localhost:8080).

I create an http.Client and set a ten second timeout — if we can’t get the image within ten seconds, we’ll abandon ship and return an error. And I create a Facebox client called fb after importing github.com/machinebox/sdk-go/facebox.

Next I added a placeholder handler:

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  // the proxy will go here
})

Before finally printing some helpful info, and running the server using ListenAndServe:

fmt.Println("Facebox at", *faceboxAddr)
fmt.Println("listening on", *addr)
fmt.Println("usage:", "http://"+*addr+"/?src=http://...")
if err := http.ListenAndServe(*addr, nil); err != nil {
  log.Fatalln(err)
}

The proxy handler (10 minutes)

The last thing I had to do is to write the code that will run when an HTTP request comes in.

This code goes inside the http.HandleFunc block that we wrote earlier.

I got the URL of the source image from the src parameter. So you can use the proxy like this:

http://myproxy.com/?src=http://domain.com/image.png

Here’s that code:

urlStr := r.URL.Query().Get(“src”)
u, err := url.Parse(urlStr)
if err != nil {
  http.Error(w, “url: “+err.Error(), http.StatusBadRequest)
  return
}
if !u.IsAbs() {
  http.Error(w, “url: absolute url required”, http.StatusBadRequest)
  return
}

url.Parse allows us to make sure the URL makes sense, and the IsAbsmethod can even help us make sure it’s an absolute path.

All being well with the URL, it was time to download the image:

resp, err := client.Get(urlStr)
if err != nil {
  http.Error(w, “download failed: “+err.Error(), http.StatusBadRequest)
  return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  http.Error(w, “download failed: “+resp.Status, resp.StatusCode)
  return
}

I use the client I created earlier to Get the image, and respond to any errors what might occur, including a non-2xx status code.

Never forget to close the body (usually by deferring it) otherwise your app will be leaking more than a thing with holes in it that isn’t supposed to have holes in it, like a bowl or a bucket. Not a sponge, which — while it does have holes in — is actually quite good at holding water.

I then download the image using iotuil.ReadAll into a []byte called b:

b, err := ioutil.ReadAll(resp.Body)
if err != nil {
  http.Error(w, “download failed: “+err.Error(), http.StatusInternalServerError)
  return
}

I need to use the image data in two places, one to send to Facebox for analysis, and the other to decode into an image.Image that my anonymise function needs.

TIP: If I only needed the data in one place, I would just use the resp.Body directly which wouldn’t necessarily mean buffering the whole image in memory.

Now I can use b to decode the image by creating a new io.Reader and passing it into image.Decode. This will fail if the file isn’t an image, or if the format isn’t supported.

img, format, err := image.Decode(bytes.NewReader(b))
if err != nil {
  http.Error(w, “image: “+err.Error(), http.StatusInternalServerError)
  return
}

img will hold the image itself, while the format string will tell me if it’s a gifjpeg or png.

Then I used the Facebox SDK to look for faces:

faces, err := fb.Check(bytes.NewReader(b))
if err != nil {
  http.Error(w, “facebox: “+err.Error(), http.StatusInternalServerError)
  return
}

The faces slice that gets returned is the same structure as my anonymise function needs, so that was the next thing to do:

anonImg := anonymise(img, faces)

The anonImg image is the source image, with all the faces redacted, and all that is left is to reply to the request with this data.

We could select a format to encode it with, but since we have the format string, let’s encode it using the same format as the original image. This is a nice touch and won’t interfere with alpha channels in PNGs or other features of the images.

Finally I added a switch block with code that set the appropriate Content-Type header on the response, and encoded the image using the appropriate package.

switch format {
  case “jpeg”:
    w.Header().Set(“Content-Type”, “image/jpg”)
    if err := jpeg.Encode(w, anonImg, &jpeg.Options{Quality: 100}); err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
    }
  case “gif”:
    w.Header().Set(“Content-Type”, “image/gif”)
    if err := gif.Encode(w, anonImg, nil); err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
    }
  case “png”:
    w.Header().Set(“Content-Type”, “image/png”)
    if err := png.Encode(w, anonImg); err != nil {
      http.Error(w, err.Error(), http.StatusInternalServerError)
      return
    }
  default:
    http.Error(w, “unsupported format: “+format, http.StatusInternalServerError)
    return
}

Let’s take a closer look at the JPEG switch case:

w.Header().Set(“Content-Type”, “image/jpg”)
err := jpeg.Encode(w, anonImg, &jpeg.Options{Quality: 100})
if err != nil {
  http.Error(w, err.Error(), http.StatusInternalServerError)
  return
}

As you can see, we set the Content-Type header to image/jpg, and the Encode function from the jpeg package to encode the anonImg to the http.ResponseWriter w. We use the best quality we can, because we want our proxy to be awesome.

And that’s it.

In a terminal, make sure Facebox is running, and run our proxy with go run main.go.

Try it out (4 minutes)

Hit up some of these URLs:

http://localhost:8000/?src=https://matryer.com/static/mat.png

It works no matter how many faces are detected:

http://localhost:8000/?src=https://machinebox.io/docs/assets/static/img/facebox-beatles.jpg

And PNGs work — with transparent backgrounds:

http://localhost:8000/?src=https://img00.deviantart.net/f2b5/i/2014/269/2/e/the_beatles_png_by_justlaugh143-d80n51m.png