From df01a03a4b9a129e938d1e9f670a7d84f1d4c75a Mon Sep 17 00:00:00 2001
From: Simone Margaritelli <evilsocket@gmail.com>
Date: Tue, 12 Nov 2019 20:18:02 +0100
Subject: [PATCH] new: pwngrid web client

---
 pwnagotchi/grid.py                            |  20 +-
 pwnagotchi/ui/web/handler.py                  |  88 +++++++
 pwnagotchi/ui/web/static/css/style.css        |   8 +
 pwnagotchi/ui/web/static/js/jquery.timeago.js | 232 ++++++++++++++++++
 pwnagotchi/ui/web/templates/inbox.html        |  80 ++++++
 pwnagotchi/ui/web/templates/message.html      |  70 ++++++
 pwnagotchi/ui/web/templates/new_message.html  |  81 ++++++
 pwnagotchi/ui/web/templates/profile.html      |  46 ++++
 8 files changed, 624 insertions(+), 1 deletion(-)
 create mode 100644 pwnagotchi/ui/web/static/js/jquery.timeago.js
 create mode 100644 pwnagotchi/ui/web/templates/inbox.html
 create mode 100644 pwnagotchi/ui/web/templates/message.html
 create mode 100644 pwnagotchi/ui/web/templates/new_message.html
 create mode 100644 pwnagotchi/ui/web/templates/profile.html

diff --git a/pwnagotchi/grid.py b/pwnagotchi/grid.py
index 93bc08a..e260d80 100644
--- a/pwnagotchi/grid.py
+++ b/pwnagotchi/grid.py
@@ -23,8 +23,10 @@ def call(path, obj=None):
     url = '%s%s' % (API_ADDRESS, path)
     if obj is None:
         r = requests.get(url, headers=None)
-    else:
+    elif isinstance(obj, dict):
         r = requests.post(url, headers=None, json=obj)
+    else:
+        r = requests.post(url, headers=None, data=obj)
 
     if r.status_code != 200:
         raise Exception("(status %d) %s" % (r.status_code, r.text))
@@ -39,6 +41,10 @@ def set_advertisement_data(data):
     return call("/mesh/data", obj=data)
 
 
+def get_advertisement_data():
+    return call("/mesh/data")
+
+
 def peers():
     return call("/mesh/peers")
 
@@ -95,3 +101,15 @@ def report_ap(essid, bssid):
 def inbox(page=1, with_pager=False):
     obj = call("/inbox?p=%d" % page)
     return obj["messages"] if not with_pager else obj
+
+
+def inbox_message(id):
+    return call("/inbox/%d" % int(id))
+
+
+def mark_message(id, mark):
+    return call("/inbox/%d/%s" % (int(id), str(mark)))
+
+
+def send_message(to, message):
+    return call("/unit/%s/inbox" % to, message)
diff --git a/pwnagotchi/ui/web/handler.py b/pwnagotchi/ui/web/handler.py
index 8db7585..435baed 100644
--- a/pwnagotchi/ui/web/handler.py
+++ b/pwnagotchi/ui/web/handler.py
@@ -1,5 +1,6 @@
 import logging
 import os
+import base64
 import _thread
 
 # https://stackoverflow.com/questions/14888799/disable-console-messages-in-flask-server
@@ -7,12 +8,15 @@ logging.getLogger('werkzeug').setLevel(logging.ERROR)
 os.environ['WERKZEUG_RUN_MAIN'] = 'true'
 
 import pwnagotchi
+import pwnagotchi.grid as grid
 import pwnagotchi.ui.web as web
 from pwnagotchi import plugins
 
 from flask import send_file
 from flask import request
+from flask import jsonify
 from flask import abort
+from flask import redirect
 from flask import render_template, render_template_string
 
 
@@ -24,6 +28,15 @@ class Handler:
         self._app.add_url_rule('/ui', 'ui', self.ui)
         self._app.add_url_rule('/shutdown', 'shutdown', self.shutdown, methods=['POST'])
         self._app.add_url_rule('/restart', 'restart', self.restart, methods=['POST'])
+
+        # inbox
+        self._app.add_url_rule('/inbox', 'inbox', self.inbox)
+        self._app.add_url_rule('/inbox/profile', 'inbox_profile', self.inbox_profile)
+        self._app.add_url_rule('/inbox/<id>', 'show_message', self.show_message)
+        self._app.add_url_rule('/inbox/<id>/<mark>', 'mark_message', self.mark_message)
+        self._app.add_url_rule('/inbox/new', 'new_message', self.new_message)
+        self._app.add_url_rule('/inbox/send', 'send_message', self.send_message, methods=['POST'])
+
         # plugins
         self._app.add_url_rule('/plugins', 'plugins', self.plugins, strict_slashes=False,
                                defaults={'name': None, 'subpath': None})
@@ -37,6 +50,81 @@ class Handler:
                                other_mode='AUTO' if self._agent.mode == 'manual' else 'MANU',
                                fingerprint=self._agent.fingerprint())
 
+    def inbox(self):
+        page = request.args.get("p", default=1, type=int)
+        inbox = {
+            "pages": 1,
+            "records": 0,
+            "messages": []
+        }
+        error = None
+
+        try:
+            inbox = grid.inbox(page, with_pager=True)
+        except Exception as e:
+            logging.exception('error while reading pwnmail inbox')
+            error = str(e)
+
+        return render_template('inbox.html',
+                               name=pwnagotchi.name(),
+                               page=page,
+                               error=error,
+                               inbox=inbox)
+
+    def inbox_profile(self):
+        data = {}
+        error = None
+
+        try:
+            data = grid.get_advertisement_data()
+        except Exception as e:
+            logging.exception('error while reading pwngrid data')
+            error = str(e)
+
+        return render_template('profile.html',
+                               name=pwnagotchi.name(),
+                               fingerprint=self._agent.fingerprint(),
+                               data=data,
+                               error=error)
+
+    def show_message(self, id):
+        message = {}
+        error = None
+
+        try:
+            message = grid.inbox_message(id)
+            if message['data']:
+                message['data'] = base64.b64decode(message['data']).decode("utf-8")
+        except Exception as e:
+            logging.exception('error while reading pwnmail message %d' % int(id))
+            error = str(e)
+
+        return render_template('message.html',
+                               name=pwnagotchi.name(),
+                               error=error,
+                               message=message)
+
+    def new_message(self):
+        to = request.args.get("to", default="")
+        return render_template('new_message.html', to=to)
+
+    def send_message(self):
+        to = request.form["to"]
+        message = request.form["message"]
+        error = None
+
+        try:
+            grid.send_message(to, message)
+        except Exception as e:
+            error = str(e)
+
+        return jsonify({"error": error})
+
+    def mark_message(self, id, mark):
+        logging.info("marking message %d as %s" % (int(id), mark))
+        grid.mark_message(id, mark)
+        return redirect("/inbox")
+
     def plugins(self, name, subpath):
         if name is None:
             # show plugins overview
diff --git a/pwnagotchi/ui/web/static/css/style.css b/pwnagotchi/ui/web/static/css/style.css
index 8661065..6c66bee 100644
--- a/pwnagotchi/ui/web/static/css/style.css
+++ b/pwnagotchi/ui/web/static/css/style.css
@@ -66,3 +66,11 @@ div.status {
     left: 0;
     width: 100%;
 }
+
+a.read {
+    color: #777 !important;
+}
+
+p.messagebody {
+    padding: 1em;
+}
\ No newline at end of file
diff --git a/pwnagotchi/ui/web/static/js/jquery.timeago.js b/pwnagotchi/ui/web/static/js/jquery.timeago.js
new file mode 100644
index 0000000..d949b77
--- /dev/null
+++ b/pwnagotchi/ui/web/static/js/jquery.timeago.js
@@ -0,0 +1,232 @@
+/**
+ * Timeago is a jQuery plugin that makes it easy to support automatically
+ * updating fuzzy timestamps (e.g. "4 minutes ago" or "about 1 day ago").
+ *
+ * @name timeago
+ * @version 1.6.7
+ * @requires jQuery >=1.5.0 <4.0
+ * @author Ryan McGeary
+ * @license MIT License - http://www.opensource.org/licenses/mit-license.php
+ *
+ * For usage and examples, visit:
+ * http://timeago.yarp.com/
+ *
+ * Copyright (c) 2008-2019, Ryan McGeary (ryan -[at]- mcgeary [*dot*] org)
+ */
+
+(function (factory) {
+  if (typeof define === 'function' && define.amd) {
+    // AMD. Register as an anonymous module.
+    define(['jquery'], factory);
+  } else if (typeof module === 'object' && typeof module.exports === 'object') {
+    factory(require('jquery'));
+  } else {
+    // Browser globals
+    factory(jQuery);
+  }
+}(function ($) {
+  $.timeago = function(timestamp) {
+    if (timestamp instanceof Date) {
+      return inWords(timestamp);
+    } else if (typeof timestamp === "string") {
+      return inWords($.timeago.parse(timestamp));
+    } else if (typeof timestamp === "number") {
+      return inWords(new Date(timestamp));
+    } else {
+      return inWords($.timeago.datetime(timestamp));
+    }
+  };
+  var $t = $.timeago;
+
+  $.extend($.timeago, {
+    settings: {
+      refreshMillis: 60000,
+      allowPast: true,
+      allowFuture: false,
+      localeTitle: false,
+      cutoff: 0,
+      autoDispose: true,
+      strings: {
+        prefixAgo: null,
+        prefixFromNow: null,
+        suffixAgo: "ago",
+        suffixFromNow: "from now",
+        inPast: 'any moment now',
+        seconds: "less than a minute",
+        minute: "about a minute",
+        minutes: "%d minutes",
+        hour: "about an hour",
+        hours: "about %d hours",
+        day: "a day",
+        days: "%d days",
+        month: "about a month",
+        months: "%d months",
+        year: "about a year",
+        years: "%d years",
+        wordSeparator: " ",
+        numbers: []
+      }
+    },
+
+    inWords: function(distanceMillis) {
+      if (!this.settings.allowPast && ! this.settings.allowFuture) {
+          throw 'timeago allowPast and allowFuture settings can not both be set to false.';
+      }
+
+      var $l = this.settings.strings;
+      var prefix = $l.prefixAgo;
+      var suffix = $l.suffixAgo;
+      if (this.settings.allowFuture) {
+        if (distanceMillis < 0) {
+          prefix = $l.prefixFromNow;
+          suffix = $l.suffixFromNow;
+        }
+      }
+
+      if (!this.settings.allowPast && distanceMillis >= 0) {
+        return this.settings.strings.inPast;
+      }
+
+      var seconds = Math.abs(distanceMillis) / 1000;
+      var minutes = seconds / 60;
+      var hours = minutes / 60;
+      var days = hours / 24;
+      var years = days / 365;
+
+      function substitute(stringOrFunction, number) {
+        var string = $.isFunction(stringOrFunction) ? stringOrFunction(number, distanceMillis) : stringOrFunction;
+        var value = ($l.numbers && $l.numbers[number]) || number;
+        return string.replace(/%d/i, value);
+      }
+
+      var words = seconds < 45 && substitute($l.seconds, Math.round(seconds)) ||
+        seconds < 90 && substitute($l.minute, 1) ||
+        minutes < 45 && substitute($l.minutes, Math.round(minutes)) ||
+        minutes < 90 && substitute($l.hour, 1) ||
+        hours < 24 && substitute($l.hours, Math.round(hours)) ||
+        hours < 42 && substitute($l.day, 1) ||
+        days < 30 && substitute($l.days, Math.round(days)) ||
+        days < 45 && substitute($l.month, 1) ||
+        days < 365 && substitute($l.months, Math.round(days / 30)) ||
+        years < 1.5 && substitute($l.year, 1) ||
+        substitute($l.years, Math.round(years));
+
+      var separator = $l.wordSeparator || "";
+      if ($l.wordSeparator === undefined) { separator = " "; }
+      return $.trim([prefix, words, suffix].join(separator));
+    },
+
+    parse: function(iso8601) {
+      var s = $.trim(iso8601);
+      s = s.replace(/\.\d+/,""); // remove milliseconds
+      s = s.replace(/-/,"/").replace(/-/,"/");
+      s = s.replace(/T/," ").replace(/Z/," UTC");
+      s = s.replace(/([\+\-]\d\d)\:?(\d\d)/," $1$2"); // -04:00 -> -0400
+      s = s.replace(/([\+\-]\d\d)$/," $100"); // +09 -> +0900
+      return new Date(s);
+    },
+    datetime: function(elem) {
+      var iso8601 = $t.isTime(elem) ? $(elem).attr("datetime") : $(elem).attr("title");
+      return $t.parse(iso8601);
+    },
+    isTime: function(elem) {
+      // jQuery's `is()` doesn't play well with HTML5 in IE
+      return $(elem).get(0).tagName.toLowerCase() === "time"; // $(elem).is("time");
+    }
+  });
+
+  // functions that can be called via $(el).timeago('action')
+  // init is default when no action is given
+  // functions are called with context of a single element
+  var functions = {
+    init: function() {
+      functions.dispose.call(this);
+      var refresh_el = $.proxy(refresh, this);
+      refresh_el();
+      var $s = $t.settings;
+      if ($s.refreshMillis > 0) {
+        this._timeagoInterval = setInterval(refresh_el, $s.refreshMillis);
+      }
+    },
+    update: function(timestamp) {
+      var date = (timestamp instanceof Date) ? timestamp : $t.parse(timestamp);
+      $(this).data('timeago', { datetime: date });
+      if ($t.settings.localeTitle) {
+        $(this).attr("title", date.toLocaleString());
+      }
+      refresh.apply(this);
+    },
+    updateFromDOM: function() {
+      $(this).data('timeago', { datetime: $t.parse( $t.isTime(this) ? $(this).attr("datetime") : $(this).attr("title") ) });
+      refresh.apply(this);
+    },
+    dispose: function () {
+      if (this._timeagoInterval) {
+        window.clearInterval(this._timeagoInterval);
+        this._timeagoInterval = null;
+      }
+    }
+  };
+
+  $.fn.timeago = function(action, options) {
+    var fn = action ? functions[action] : functions.init;
+    if (!fn) {
+      throw new Error("Unknown function name '"+ action +"' for timeago");
+    }
+    // each over objects here and call the requested function
+    this.each(function() {
+      fn.call(this, options);
+    });
+    return this;
+  };
+
+  function refresh() {
+    var $s = $t.settings;
+
+    //check if it's still visible
+    if ($s.autoDispose && !$.contains(document.documentElement,this)) {
+      //stop if it has been removed
+      $(this).timeago("dispose");
+      return this;
+    }
+
+    var data = prepareData(this);
+
+    if (!isNaN(data.datetime)) {
+      if ( $s.cutoff === 0 || Math.abs(distance(data.datetime)) < $s.cutoff) {
+        $(this).text(inWords(data.datetime));
+      } else {
+        if ($(this).attr('title').length > 0) {
+            $(this).text($(this).attr('title'));
+        }
+      }
+    }
+    return this;
+  }
+
+  function prepareData(element) {
+    element = $(element);
+    if (!element.data("timeago")) {
+      element.data("timeago", { datetime: $t.datetime(element) });
+      var text = $.trim(element.text());
+      if ($t.settings.localeTitle) {
+        element.attr("title", element.data('timeago').datetime.toLocaleString());
+      } else if (text.length > 0 && !($t.isTime(element) && element.attr("title"))) {
+        element.attr("title", text);
+      }
+    }
+    return element.data("timeago");
+  }
+
+  function inWords(date) {
+    return $t.inWords(distance(date));
+  }
+
+  function distance(date) {
+    return (new Date().getTime() - date.getTime());
+  }
+
+  // fix for IE6 suckage
+  document.createElement("abbr");
+  document.createElement("time");
+}));
diff --git a/pwnagotchi/ui/web/templates/inbox.html b/pwnagotchi/ui/web/templates/inbox.html
new file mode 100644
index 0000000..f04b4e0
--- /dev/null
+++ b/pwnagotchi/ui/web/templates/inbox.html
@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+
+    <title>{{ name }} Inbox</title>
+
+    <link rel="stylesheet" href="http://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css"/>
+    <script src="http://code.jquery.com/jquery-1.11.1.min.js"></script>
+    <script src="http://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js"></script>
+    <script type="text/javascript" src="/js/jquery.timeago.js"></script>
+
+    <link rel="stylesheet" type="text/css" href="/css/style.css"/>
+
+    <script type="text/javascript">
+        $.mobile.ajaxEnabled = false;
+
+        jQuery(document).ready(function() {
+          jQuery("time.timeago").timeago();
+        });
+    </script>
+</head>
+<body>
+<div data-role="page">
+
+    {% if error %}
+    <div class="error">{{ error }}</div>
+    {% else %}
+    {% if inbox.records == 0 %}
+    <small>Inbox is empty.</small>
+    {% else %}
+
+    <div data-role="footer">
+        <div data-role="navbar">
+            <ul>
+                <li><a href="/inbox" id="email" class="ui-btn-active" data-icon="bars">Inbox</a></li>
+                <li><a href="/inbox/new" id="new" data-icon="mail">New</a></li>
+                <li><a href="/inbox/profile" id="profile" data-icon="user">Profile</a></li>
+            </ul>
+        </div>
+    </div>
+
+    <ul class="inbox" data-role="listview" data-inset="true">
+        {% for message in inbox.messages %}
+        <li class="message">
+            <a href="/inbox/{{ message.id }}" class="{{ 'unread' if not message.seen_at else 'read' }}">
+                <h2>{{ message.sender_name }}@{{ message.sender }}</h2>
+                <p>
+                    Received
+                    <time class="timeago" datetime="{{ message.created_at }}">{{ message.created_at }}</time>
+                    {% if message.seen_at %}, seen
+                    <time class="timeago" datetime="{{ message.seen_at }}">{{ message.seen_at }}</time>
+                    {% endif %}.
+                </p>
+            </a>
+        </li>
+        {% endfor %}
+    </ul>
+    {% endif %}
+
+    {% if inbox.pages > 1 %}
+    <div data-role="navbar">
+        <ul>
+            {% if page > 1 %}
+            <li><a href="/inbox?p={{ page - 1 }}" class="ui-btn">Prev</a></li>
+            {% endif %}
+            {% if page < inbox.pages %}
+            <li><a href="/inbox?p={{ page + 1 }}" class="ui-btn">Next</a></li>
+            {% endif %}
+        </ul>
+    </div>
+    {% endif %}
+
+    {% endif %}
+
+</div>
+
+</body>
+</html>
diff --git a/pwnagotchi/ui/web/templates/message.html b/pwnagotchi/ui/web/templates/message.html
new file mode 100644
index 0000000..7390c03
--- /dev/null
+++ b/pwnagotchi/ui/web/templates/message.html
@@ -0,0 +1,70 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+
+    <title>Message from {{ message.sender_name }}</title>
+
+    <link rel="stylesheet" href="http://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css"/>
+    <script src="http://code.jquery.com/jquery-1.11.1.min.js"></script>
+    <script src="http://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js"></script>
+    <script type="text/javascript" src="/js/jquery.timeago.js"></script>
+
+    <link rel="stylesheet" type="text/css" href="/css/style.css"/>
+
+    <script type="text/javascript">
+        $.mobile.ajaxEnabled = false;
+
+        jQuery(document).ready(function() {
+          jQuery("time.timeago").timeago();
+          // mark the message as read
+          jQuery.get("/inbox/{{ message.id }}/seen", function(res){
+            console.log(res);
+          });
+        });
+
+    </script>
+
+</head>
+<body>
+<div data-role="page">
+
+    {% if error %}
+    <div class="error">{{ error }}</div>
+    {% else %}
+
+    <div data-role="footer">
+        <div data-role="navbar">
+            <ul>
+                <li><a href="/inbox" id="email" data-icon="back">Back</a></li>
+                <li><a href="/inbox/new?to={{ message.sender }}" id="reply" data-icon="edit">Reply</a></li>
+                <li><a href="/inbox/{{ message.id }}/deleted" id="delete" data-icon="delete"
+                       onclick="return confirm('Are you sure?')">Delete</a></li>
+            </ul>
+        </div>
+    </div>
+
+    <p class="messagebody">
+        <span style="color: #888">
+        Message from {{ message.sender_name }}@{{ message.sender }}, received
+        <time class="timeago" datetime="{{ message.created_at }}">{{ message.created_at }}</time>
+        {% if message.seen_at %}, seen
+        <time class="timeago" datetime="{{ message.seen_at }}">{{ message.seen_at }}</time>{% endif %}.</span>
+
+        <br/>
+        <br/>
+
+        {% if message.data %}
+            <span style="white-space: pre-line">{{ message.data }}</span>
+        {% else %}
+            <small>This message is empty.</small>
+        {% endif %}
+    </p>
+
+    {% endif %}
+
+</div>
+
+</body>
+</html>
diff --git a/pwnagotchi/ui/web/templates/new_message.html b/pwnagotchi/ui/web/templates/new_message.html
new file mode 100644
index 0000000..4200259
--- /dev/null
+++ b/pwnagotchi/ui/web/templates/new_message.html
@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+
+    {% if to %}
+    <title>Reply to {{ to }}</title>
+    {% else %}
+    <title>New Message</title>
+    {% endif %}
+
+    <link rel="stylesheet" href="http://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css"/>
+    <script src="http://code.jquery.com/jquery-1.11.1.min.js"></script>
+    <script src="http://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js"></script>
+    <script type="text/javascript" src="/js/jquery.timeago.js"></script>
+
+    <link rel="stylesheet" type="text/css" href="/css/style.css"/>
+
+    <script type="text/javascript">
+        $.mobile.ajaxEnabled = false;
+
+        $(function(){
+            $("#message_form").submit(function(e) {
+                e.preventDefault();
+
+                var form = $(this);
+                var url = form.attr('action');
+
+                $.ajax({
+                   type: "POST",
+                   url: url,
+                   data: form.serialize(),
+                   success: function(data) {
+                        if( data.error ) {
+                            if( data.error.indexOf('404') != -1 )
+                                alert('Fingerprint not found.');
+                            else if( data.error.indexOf('aborted') != -1 )
+                                alert('Empty or invalid message.');
+                            else
+                                alert(data.error);
+                            return;
+                        }
+
+                        alert("Message sent!");
+                        document.location.href = "/inbox";
+                   }
+                });
+            });
+        });
+
+    </script>
+
+</head>
+<body>
+<div data-role="page">
+
+    <div data-role="footer">
+        <div data-role="navbar">
+            <ul>
+                <li><a href="/inbox" id="email" data-icon="back">Back</a></li>
+            </ul>
+        </div>
+    </div>
+
+    <div style="padding: 1em">
+        <form id="message_form" method="POST" action="/inbox/send">
+            <label for="to">To:</label>
+            <input type="text" name="to" id="to" value="{{ to }}">
+
+            <label for="message">Message:</label>
+            <textarea cols="40" rows="8" name="message" id="message"></textarea>
+            <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
+            <input type="submit" class="button" value="Send"/>
+        </form>
+    </div>
+
+</div>
+
+</body>
+</html>
diff --git a/pwnagotchi/ui/web/templates/profile.html b/pwnagotchi/ui/web/templates/profile.html
new file mode 100644
index 0000000..49984f5
--- /dev/null
+++ b/pwnagotchi/ui/web/templates/profile.html
@@ -0,0 +1,46 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+
+    <title>Profile</title>
+
+    <link rel="stylesheet" href="http://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css"/>
+    <script src="http://code.jquery.com/jquery-1.11.1.min.js"></script>
+    <script src="http://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js"></script>
+    <script type="text/javascript" src="/js/jquery.timeago.js"></script>
+
+    <link rel="stylesheet" type="text/css" href="/css/style.css"/>
+
+    <script type="text/javascript">
+        $.mobile.ajaxEnabled = false;
+    </script>
+
+</head>
+<body>
+<div data-role="page">
+
+    <div data-role="footer">
+        <div data-role="navbar">
+            <ul>
+                <li><a href="/inbox" id="email" data-icon="back">Back</a></li>
+            </ul>
+        </div>
+    </div>
+
+    <div style="padding: 1em">
+        <label for="name">Name</label>
+        <h4 id="name">{{ name }}</h4>
+
+        <label for="fingerprint">Fingerprint</label>
+        <h4 id="fingerprint">{{ fingerprint }}</h4>
+
+        <label for="data">Data</label>
+        <pre><code id="data">{{ data }}</code></pre>
+    </div>
+
+</div>
+
+</body>
+</html>