I recently ran into a situation where a client of mine (my only “client,” who also happens to be my Mom) wanted to sell a digital video on her website. Until now, everything she sold had been a physical product, so I was able to get away with simple Paypal forms on her web store. Someone would click the “Buy Now” button, and Paypal would email my client so she knew to package and ship the product.

That workflow is fine for physical goods, but doesn’t really hold up for digital downloads—in theory my client could still “package and ship” the video by sending it as an email attachment, but the file is too large for that to be practical. A better option would be for me to put the video on the server, and let my client send the link out to people who had purchased it. The problem with that is that the audience for this particular video is a fairly tight-knit community, and it probably wouldn’t be long before the link to the video was shared (either intentionally or not) which would render the whole purchase step no longer necessary. Protecting the file with a password would be marginally better, but the password could be shared just as easily as the link.

My first thought was to develop some kind of home-grown expiring link system along these lines (pseudocode):

# Admin Tool for Client
func generate_temporary_link(){
  token = generate_random_token();
  database.insert({Token: token, Created: Date.now()});
  return "http://download.url/?token=" + token;

# Download Handler for Purchaser
func download_file(token){
  entry = database.select({Token: token});
  if(Date.now() - entry.Created > DateSpan.FromDays(3)){
    throw("Sorry, your link has expired.");
  return FileStream.From("/private/local/path/to/video.mp4");

My only issue with that approach is that it would be a lot of friggin’ work. I’d have to define the database schema, create the admin service, do date math (I really hate date math), and basically write a bunch of code that seemed like it was solving a problem that should have already been solved before.

I was pretty sure that my client wouldn’t mind paying a nominal fee per download to utilize an existing service that provided expiring download links, so I did a little research to see what was already out there. These are some of the options I found (in no particular order) along with the reasons why they weren’t very appealing to me:

After being disappointed by most of the offerings in this space (I wonder if it’s worth spinning up a competitor of my own?), I decided to build my own home-grown solution after all.

My plan was to store the video in a non-public S3 bucket and then write some kind of expiring-link proxy to it. It was while I was looking for pre-existing S3 proxies that I stumbled upon this little nugget in the AWS.S3 getSignedUrl examples for the Javascript AWS SDK:

// Passing in a 1-minute expiry time for a pre-signed URL
var params = {Bucket: 'bucket', Key: 'key', Expires: 60};
var url = s3.getSignedUrl('getObject', params);
console.log('The URL is', url); // expires in 60 seconds

Holy crap! Amazon S3 already supports expiring links to content! This means all I need to do is give my client a tool to generate those links. No database schemas, no download proxying, and (best of all) no date math! Well, maybe a little bit of date math, but it’s the kind that you can do with a simple lookup.

Usually I’d use Perl for something like this, but I’ve been on a Node.js/CoffeeScript kick lately. In retrospect PHP probably would have been best but I don’t actually know any PHP so this is what I ended up with:

fs = require 'fs'
express = require 'express'
auth = require 'basic-auth'
aws = require 'aws-sdk'
bitlygen = require 'bitly'

app = express()

fs.readFile "#{ __dirname }/config.json", 'utf8', (err, data) ->
  if err?
    console.log err
    config = JSON.parse data
    s3 = new aws.S3 config.aws
    bitly = new bitlygen config.bitly.username, config.bitly.apikey

    app.get '/get-url/:folder/:key', (req, res) ->
      creds = auth req
      if creds? and creds.name == config.authuser and creds.pass == config.authpass
        url = s3.getSignedUrl 'getObject',
          Bucket: config.bucket
          Key: req.params.folder + '/' + req.params.key
          Expires: 259200 # 3 days in seconds
        bitly.shorten url, (err, resp) ->
          if err?
            console.log err
            res.status(500).send 'Error 500 - Contact Rudi'
            res.send resp.data.url
        res.setHeader 'WWW-Authenticate', "Basic realm=\"#{ req.hostname }\""
        res.status(401).send 'Error 401 - Unauthenticated'

    server = app.listen 8123, ->
      console.log 'Listening on port 8123'

The script requires a config.json file next to it that looks like this:

  "authuser": "myuser",
  "authpassword": "mypassword",
  "bucket": "my-private-bucket-name",
    "username": "bitlyusername",
    "apikey": "MY_BITLY_API_KEY"
    "accessKeyId": "MY_AWS_ACCESS_KEY_ID",
    "secretAccessKey": "MY_AWS_SECRET_ACCESS_KEY",
    "region": "us-east-1"

The script is written in a way that it can generate expiring links to any file in the configured bucket. My client visits the site at http://admin.site/clientname/video.mp4 and the script requests the signed link from Amazon (using the key clientname/video.mp4), telling it to expire the link in 3 days. The link is shortened using Bitly (the AWS link is quite long and would probably be broken onto multiple lines by some email programs which could cause problems), and displays the Bitly link to the client. The client can then copy that link and send it to someone who purchased their video. Each purchaser gets their own link which will stop working after 3 days.

There are still some downsides to this method—it would be nice if the link could be emailed to the client automatically, for example, and the error message displayed by Amazon after the link expires is pretty unfriendly, but for low-volume sales I’m pretty satisfied with this as a low-effort completely free solution to the problem.

At least until I spin up my own simple digital sales platform to compete with the disappointing and/or bloated options already out there. Or not.