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>