Open PhoneCam: an open source mashup using HTML5, Javascript and Ruby

As we all race headlong into our respective technology futures, we are continually leaving behind a wealth of personal gadgets we think are now obsolete. Recently I discovered a great use for the cameras of older smartphones. With a little Javascript they can be easily transformed and repurposed into security cameras or other unattended sources of high quality images. In this post, I want to demonstrate the application of HTML 5 together with a few Open Source tools. Mashups like these can breathe new life into older phones and other devices we may have relegated to forgotten corners around our homes.

There are client-side and server-side components to this open source project. First, there is a client-side HTML/Javascript page for providing a simple UX harness and for device (camera) manipulation. Also on the client-side, I am using Joseph Huckaby’s Javascript media library. Server-side components include an SSL Web server (Nginx) and Ruby/Sinatra/Unicorn Middleware for handling file uploading and providing other services. The objective of the project is to enable a phone or other client to snap a picture and then upload the image to a remote server. Picture taking can be initiated manually or it can be repeated automatically at specified intervals.

The code of this project has been tested on a variety of Android phones and tablets using mainly Chrome. However, it doesn’t currently work on iOS devices.

The Camera Controller Web Page

Chrome on a phone or other device now requires a “secure origin” source to allow Javascript’s getUserMedia function to access certain of the device’s hardware, including the camera (see Only secure origins are allowed). One way to provide a secure origin source is to set up an SSL proxy to handle the Web request that retrieves and then uses getUserMedia. The proxy provides a secure origin endpoint and can be easily set up using an SSL capable Web server like Nginx. Another way is to enable an SSL connection directly in the same middleware Web application that provides the image file upload service endpoint. I’ll illustrate both approaches in this article. But, first let’s look at the camera controller Web page and its associated Javascript:

camera_harness.html:

<!doctype html>

<html lang="en">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>PhoneCam Demo Page</title>
  <style type="text/css">
    body { font-family: Helvetica, sans-serif; }
    h2 { margin-top:0; }
    form > input { margin-right: 15px; }
    #results { padding:20px; border:1px solid; background:#ccc; }
    .left_side { float:left; }
    .right_side { float:right; }
    #selector_section { padding-right: 8px; }
    #activate_debug_mode { padding-right: 8px; }
  </style>
  <script type="text/javascript" src="/js/webcam.js"></script>
  <script type="text/javascript" src="/js/manage_camera.js"></script>
</head>
<body>
  <h2>PhoneCam Demo Page</h2>
  <div id="live_camera" style="display:none;"></div>
  <div id="activate_debug_section" class="left_side">
    <input type="checkbox" id="snapshot_debug_mode" name="Activate Debug Mode" onClick="ManageCamera.manageSettings.activateDebugMode(this)">
    Activate Debug Mode
  </div>
  <br /><br />
  <div class="left_side">
    <form id="take_snapshot_form" style="display:none">
      <input type="button" id="take_snapshot" value="Take Snapshot" onClick="ManageCamera.takeSnapshot()">
      <br /><br />
      <span>Snapshot interval (in seconds): </span><input type="text" id="snapshot_interval" onChange="ManageCamera.manageSettings.setSnapshotInterval()">
    </form>
    <br />
    <div class="left_side">
      <div id="upload_response">Upload response: </div>
      <div id="camera_list"></div>
      <br />
      <div id='selector_section' class="left_side">Camera:
        <select id="camera_selector" onChange="ManageCamera.attachCamera(this.value)">
        </select>
      </div>
      <br /><br />
    </div>
  </div>
  <div id="results" class="right_side" style="display: none;">Snapshot here...</div>
  <script language="JavaScript" src="/js/start_camera.js"></script>
</body>
</html>

This webpage provides containers for viewing a live camera feed and for showing the image captured from taking a snapshot. It also provides controls for activating debug mode, snapping a picture and selecting an available camera.

manage_camera.js:

var ManageCamera = (function () {
  var videoLabels = [];
  var getCookie = function(name) {
      var cleanCookie = decodeURIComponent(document.cookie),
          nibbles = cleanCookie.split(';');
      for(var i = 0; i <nibbles.length; i++) {
          var crumbs = nibbles[i].split('=');
          if (crumbs[0].trim() == name) { return crumbs[1].trim(); }
      }
      return "";
  };
  var format_date = function() {
    var m = new Date();
    return m.getUTCFullYear() + "_" +
        ("0" + (m.getUTCMonth()+1)).slice(-2) + "_" +
        ("0" + m.getUTCDate()).slice(-2) + "_" +
        ("0" + m.getUTCHours()).slice(-2) + ":" +
        ("0" + m.getUTCMinutes()).slice(-2) + ":" +
        ("0" + m.getUTCSeconds()).slice(-2);
  };
  return {
    genCameraSelector: function(videoSources) {
      console.log("----> genCameraSelector");
      console.log("----> in genCameraSelector, videoSources: "+videoSources);
      var cameraSelector = document.getElementById("camera_selector");
      videoSources.forEach(function(device) {
        videoLabels.push("<option value='" + device.id + "'>"+(device.label || "camera" + (videoLabels.length + 1)) + "</option>");
      });
      cameraSelector.innerHTML = videoLabels.join("\n");
    },
    getVideoSources: function(cb) {
      console.log("----> getVideoSources");
      var videoSources = [];
      var enumerateCams;
      if (typeof(navigator.mediaDevices) == "object" && typeof(navigator.mediaDevices.enumerateDevices) == "function") {
        // fetch new-style promise
        enumerateCams = navigator.mediaDevices.enumerateDevices.call(navigator.mediaDevices);
      } else if (typeof(MediaStreamTrack) == "function") {
        // encapsulate old-style API in new-style promise
        enumerateCams = new Promise(function(resolve, reject) {
          MediaStreamTrack.getSources(function(devices) {
            resolve(devices);
        })});
      } else {
        throw "Cannot find media device enumerator";
      }
      enumerateCams.then(function(devices) {
        devices.forEach(function(d){
          console.log(d)
          if (d.kind.match(/^video/)) {
            videoSources.push({id: (d.deviceId || d.id), label: d.label || "camera" + (videoSources.length + 1)});
          }
        })
        cb(videoSources);
      });
    },
    attachCamera: function(id) {
      console.log("----> attachCamera, id: "+id);
      Webcam.reset();
      var wh = {h: 240, w: 320}
      Webcam.set({
        width: wh['w'],
        height: wh['h'],
        dest_width: wh['w'],
        dest_height: wh['h'],
        image_format: 'jpeg',
        jpeg_quality: 90,
        constraints: {mandatory: {maxWidth: wh['w'], maxHeight: wh['h']}, optional: [{sourceId: id}]}
      });
      Webcam.attach('#live_camera');
    },
    takeSnapshot: function() {
      console.log("----> takeSnapshot");
      var upload_name = "phonecam_"+format_date();
      Webcam.set("upload_name", upload_name);
      Webcam.snap(function(data_uri) {
        document.getElementById('results').innerHTML = 
          '<h2>Your image: '+upload_name+'</h2>' + 
          '<img src="'+data_uri+'"/>';
        Webcam.upload(data_uri, '/upload', function(code, text) {
          upload_response = "code: " + code + ", text: " + text;
          document.getElementById('upload_response').innerHTML = upload_response; 
        });
      });
    },
    manageSettings: {
      initSnapshotInterval: function() {
        console.log("----> manageSettings.initSnapshotInterval");
        var cookie = getCookie("snapshot_interval");
        if (cookie == "") {
          cookie = "3600";
          document.cookie = "snapshot_interval="+cookie+"; path=/";
        }
        var interval_element = document.getElementById("snapshot_interval");
        interval_element.value = cookie;
      },
      setSnapshotInterval: function() {
        console.log("----> manageSettings.setSnapshotInterval");
        var interval_element = document.getElementById("snapshot_interval");
        var isnapshot_interval = parseInt(interval_element.value, 10); // ensure its a number
        document.cookie = "snapshot_interval="+isnapshot_interval+"; path=/";
      },
      getSnapshotInterval: function() {
        console.log("----> manageSettings.getSnapshotInterval");
        var cookie = getCookie("snapshot_interval");
        var isnapshot_interval = parseInt(cookie, 10);
        return isnapshot_interval;
      },
      initSnapshotDebugMode: function() {
        console.log("----> manageSettings.initSnapshotDebugMode");
        var cookie = getCookie("snapshot_debug_mode");
        var debug_mode_element = document.getElementById("snapshot_debug_mode");
        debug_mode_element.checked = cookie == "true";
      },
      setSnapshotDebugMode: function(debug_mode_element) {
        console.log("----> manageSettings.setSnapshotDebugMode");
        document.cookie = "snapshot_debug_mode="+debug_mode_element.checked+"; path=/";
      },
      getSnapshotDebugMode: function() {
        console.log("----> manageSettings.getSnapshotDebugMode");
        var cookie = getCookie("snapshot_debug_mode");
        return cookie == "true";
      },
      initSettings: function() {
        console.log("----> manageSettings.initSettings");
        ManageCamera.manageSettings.initSnapshotInterval();
        ManageCamera.manageSettings.initSnapshotDebugMode();
      },
      clearSettings: function() {
        console.log("----> manageSettings.clearSettings");
        document.cookie = "snapshot_interval=; path=/";
        document.cookie = "snapshot_debug_mode=; path=/";
      },
      activateDebugMode: function(chkbx) {
        console.log("----> manageSettings.activateDebugMode");
        var take_snapshot_form = document.getElementById("take_snapshot_form"),
            results = document.getElementById("results"),
            live_camera = document.getElementById("live_camera");
        var display_state = chkbx.checked ? "display:block" : "display:none";
        take_snapshot_form.style = display_state;
        results.style = display_state;
        live_camera.style = display_state;
        ManageCamera.manageSettings.setSnapshotDebugMode(chkbx);
      }
    }
  }
})();

manage_camera.js is the heart of the project. The ManageCamera object is assigned a hash of functions used to control the media stream hardware (the camera) and to manage the session settings such as debug mode and snapshot interval.

start_camera.js:

ManageCamera.getVideoSources(function(videoSources){
  var manageSettings =  ManageCamera.manageSettings;
  var videoSource = videoSources[0];
  var wh = {h: 240, w: 320}
  var constraints = videoSource ?  {mandatory: {maxWidth: wh['w'], maxHeight: wh['h']}, optional: [{sourceId: videoSource.id}]} : {mandatory: {maxWidth: wh['w'], maxHeight: wh['h']}}
  Webcam.set({
    width: wh['w'],
    height: wh['h'],
    dest_width: wh['w'],
    dest_height: wh['h'],
    image_format: 'jpeg',
    jpeg_quality: 90,
    constraints: constraints
  });
  Webcam.attach('#live_camera');
  ManageCamera.genCameraSelector(videoSources);
  manageSettings.initSettings();
  var interval = manageSettings.getSnapshotInterval() * 1000;
  setInterval(ManageCamera.takeSnapshot, interval);
  var debugMode = manageSettings.getSnapshotDebugMode();
  debugMode && manageSettings.activateDebugMode({checked: true});
});

start_camera.js initializes the media stream interface with default values and sets debug mode and the snapshot interval from remembered values. It is executed at the end of page load.

webcam.js:

I’m using Joseph Huckaby’s image capture library, Webcamjs, originally based on Google’s JPEGCam. It’s an older version of his library but works just fine for our purposes.

Using Nginx as a secure origin Javascript source

Nginx can easily be set up as a proxy for SSL connections. As a Web front end, it’s fast, provides good security and can use Unix sockets to isolate middleware components from the net.

Nginx: nginx.conf (add upstream directive to http block):

  upstream unicorn_server {
    server unix:/opt/snapshot/tmp/sockets/unicorn.sock
    fail_timeout=0;
  }

nginx: sites-available/snapshot:

Note that an SSL certificate and its key are specified in snapshot’s site server block and point to a location in the ssl subfolder of the nginx installation. You may create the SSL certificate and key by generating an OpenSSL self-signed certificate or by using some other method. For more details, see for example How To Create a Self-Signed SSL Certificate for Nginx

server {
	##listen 80 default_server;
	##listen [::]:80 default_server;

	# SSL configuration
	#
	listen 443 ssl default_server;
	listen [::]:443 ssl default_server;
	#
	# Note: You should disable gzip for SSL traffic.
	# See: https://bugs.debian.org/773332
	#
	# Read up on ssl_ciphers to ensure a secure configuration.
	# See: https://bugs.debian.org/765782
	#
	# Self signed certs generated by the ssl-cert package
	# Don't use them in a production server!
	#
	# include snippets/snakeoil.conf;
        client_max_body_size 4G;
        keepalive_timeout 5;

	##root /var/www/html;
	root /opt/snapshot/html;

	# Add index.php to the list if you are using PHP
	index index.html index.htm index.nginx-debian.html;

	server_name _;
        ssl_certificate /etc/nginx/ssl/nginx.crt;
        ssl_certificate_key /etc/nginx/ssl/nginx.key;

	location / {
		# First attempt to serve request as file, then
		# as directory, then fall back to displaying a 404.
		try_files $uri $uri/ =404;
	}

        location ~ /upload {
          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header Host $http_host;
          proxy_redirect off;
          # pass to the upstream unicorn server mentioned above
          proxy_pass http://unicorn_server;
        }
}

Sinatra and Unicorn (Nginx dependency)

Sinatra demonstrates its ability to produce quick and easy Web services including, in this project, an easy image file upload interface. Up until this point, the application depends on the Nginx setup illustrated above. Later, an alternative SSL version of the Sinatra application is provided.

snapapp.rb:

require "rubygems"
require "sinatra/base"

$target_dir = "./tmp/"
class SnapApp < Sinatra::Base
  post '/upload' do
    cam = {}
    params.select{|k,v| k =~ /^phonecam_/ && cam = v} # select on date suffixed key
    unless cam &&
           (tmpfile = cam[:tempfile]) &&
           (name = cam[:filename])
      return "No uploaded file found"
    end
    STDERR.puts "Uploading file, original name #{name.inspect}"
    content = ""
    while blk = tmpfile.read(65536)
      content += blk
    end
    File.open($target_dir+name, 'wb') {|f| f.write content }
    "Upload complete<br/>#{Time.now.strftime('%m-%e-%y %H:%M')}"
  end
end

Unicorn

Unicorn, an updated version of Mongrel, provides a Rack HTTP server for Ruby applications. The HTTP server has been configured to use Unix sockets for enhanced privacy and to avoid possible port conflicts. Here’s the configuration file:

unicorn.rb:

@dir = "/opt/snapshot/"

worker_processes 2
working_directory @dir

timeout 30

# Specify path to socket unicorn listens to,
# we will use this in our nginx.conf later
listen "#{@dir}tmp/sockets/unicorn.sock", :backlog => 64

# Set process id path
pid "#{@dir}tmp/pids/unicorn.pid"

# Set log file paths
stderr_path "#{@dir}log/unicorn.stderr.log"
stdout_path "#{@dir}log/unicorn.stdout.log"

Sinatra and WEBrick (no Nginx)

There are several ways to avoid having to use Nginx, or another richly endowed web server, to provide a secure origin SSL endpoint. For example, SSL can be enabled directly within Sinatra and WEBrick. While not an optimised solution for production systems, this strategy can simplify and speed up development. For more details, see SSL for a Standalone Sinatra App or How to Make Sinatra Work over HTTPS. Here is an adaptation of these ideas to our file upload handler using a monkeypatch for Sinatra’s Application#run! method.

sslsnap.rb:

require "rubygems"
require 'sinatra'
require "sinatra/base"
require 'webrick/https'

module Sinatra
  class Application
    def self.run!
      certificate_content = File.open(ssl_certificate).read
      key_content = File.open(ssl_key).read

      server_options = {
        :Host => bind,
        :Port => port,
        :SSLEnable => true,
        :SSLCertificate => OpenSSL::X509::Certificate.new(certificate_content),
        :SSLPrivateKey => OpenSSL::PKey::RSA.new(key_content)
      }

      Rack::Handler::WEBrick.run self, server_options do |server|
        [:INT, :TERM].each { |sig| trap(sig) { server.stop } }
        server.threaded = settings.threaded if server.respond_to? :threaded=
        set :running, true
      end
    end
  end
end

set :bind, '0.0.0.0'
set :port, 443
set :ssl_certificate, "server.crt"
set :ssl_key, "server.key"
set :target_dir, "/opt/snapshot/tmp/"

get '/' do
  send_file("html/index.html")
end

get '/camera_harness.html' do
  send_file("html/camera_harness.html")
end

get '/js/:f' do
  send_file("html/js/#{params[:f]}")
end

post '/upload' do
  cam = {}
  params.select{|k,v| k =~ /^phonecam_/ && cam = v} # select on date suffixed key
  unless cam &&
         (tmpfile = cam[:tempfile]) &&
         (name = cam[:filename])
    return "No uploaded file found"
    #return "params: #{params}"
  end
  STDERR.puts "Uploading file, original name #{name.inspect}"
  content = ""
  while blk = tmpfile.read(65536)
    content += blk
  end
  File.open(settings.target_dir+name, 'wb') {|f| f.write content }
  "Upload complete<br/>#{Time.now.strftime('%m-%e-%y %H:%M')}"
end

Using the Application

Once you have set up the application, simply point the browser on your phone to https://[your host address]/index.html. Then select the camera you wish to use (e.g., front or rear) from the select camera dropdown. You can click on the ‘Activate Debug Mode’ button to view a live stream from the camera you have selected. Also, when in debug mode, the screen will display the latest snapshot uploaded and let you enter the delay between automatic snapshots (defaults to one hour). The debug screen allows you to manually take a picture by pressing the ‘take snapshot’ button.

The source code for this project can be found at Snapshot: An HTML 5, Javascript and Ruby Mashup to Build an Open Source Webcam for Phones or Other Devices.

This post has featured a mashup of several practical, but somewhat independent, Web technologies: Javascript, Ruby, and even a proxy Web server. In an upcoming blog post, I will illustrate some benefits of greater consistency by converting and streamlining this project using ES6 and Node.js.

Screenshot of PhoneCam in action:
Please note that ‘https’ in the URL shown in the screenshot below is flagged with a warning because we’re using an ‘insecure’ self-signed certificate for testing.

Screenshot of PhoneCam in action

Iterations on Open Software Innovation by Robert Adkins