浏览代码

Add original panel from 2.7.0

DSR! 4 年之前
当前提交
4a80e0e228
共有 100 个文件被更改,包括 10730 次插入0 次删除
  1. 3 0
      src/pineapple/.gitignore
  2. 244 0
      src/pineapple/api/API.php
  3. 26 0
      src/pineapple/api/APIModule.php
  4. 134 0
      src/pineapple/api/Authentication.php
  5. 103 0
      src/pineapple/api/DatabaseConnection.php
  6. 132 0
      src/pineapple/api/Module.php
  7. 77 0
      src/pineapple/api/Modules.php
  8. 63 0
      src/pineapple/api/Notifications.php
  9. 238 0
      src/pineapple/api/Setup.php
  10. 25 0
      src/pineapple/api/SystemModule.php
  11. 8 0
      src/pineapple/api/index.php
  12. 142 0
      src/pineapple/api/pineapple.php
  13. 4 0
      src/pineapple/css/bootstrap.min.css
  14. 223 0
      src/pineapple/css/main.css
  15. 73 0
      src/pineapple/html/clone-modal.html
  16. 108 0
      src/pineapple/html/hook-modal.html
  17. 60 0
      src/pineapple/html/install-modal.html
  18. 二进制
      src/pineapple/img/browser_chrome.png
  19. 二进制
      src/pineapple/img/browser_ff.png
  20. 二进制
      src/pineapple/img/browser_ie.png
  21. 二进制
      src/pineapple/img/browser_opera.png
  22. 二进制
      src/pineapple/img/browser_safari.png
  23. 二进制
      src/pineapple/img/favicon.ico
  24. 二进制
      src/pineapple/img/logo.png
  25. 二进制
      src/pineapple/img/logout.png
  26. 二进制
      src/pineapple/img/notify.png
  27. 二进制
      src/pineapple/img/throbber.gif
  28. 118 0
      src/pineapple/index.html
  29. 114 0
      src/pineapple/js/controllers.js
  30. 927 0
      src/pineapple/js/directives.js
  31. 103 0
      src/pineapple/js/filters.js
  32. 73 0
      src/pineapple/js/helpers.js
  33. 32 0
      src/pineapple/js/pineapple.js
  34. 109 0
      src/pineapple/js/services.js
  35. 9 0
      src/pineapple/js/vendor/angular-cookies.min.js
  36. 17 0
      src/pineapple/js/vendor/angular-route.min.js
  37. 335 0
      src/pineapple/js/vendor/angular.min.js
  38. 5 0
      src/pineapple/js/vendor/bootstrap.min.js
  39. 1 0
      src/pineapple/js/vendor/jquery.min.js
  40. 276 0
      src/pineapple/modules/Advanced/api/module.php
  41. 15 0
      src/pineapple/modules/Advanced/formatSD/fdisk_options
  42. 42 0
      src/pineapple/modules/Advanced/formatSD/format_sd
  43. 293 0
      src/pineapple/modules/Advanced/js/module.js
  44. 185 0
      src/pineapple/modules/Advanced/module.html
  45. 12 0
      src/pineapple/modules/Advanced/module.info
  46. 28 0
      src/pineapple/modules/Advanced/module_icon.svg
  47. 116 0
      src/pineapple/modules/Clients/api/module.php
  48. 32 0
      src/pineapple/modules/Clients/js/module.js
  49. 53 0
      src/pineapple/modules/Clients/module.html
  50. 12 0
      src/pineapple/modules/Clients/module.info
  51. 16 0
      src/pineapple/modules/Clients/module_icon.svg
  52. 44 0
      src/pineapple/modules/Configuration/api/landingpage_index.php
  53. 210 0
      src/pineapple/modules/Configuration/api/module.php
  54. 284 0
      src/pineapple/modules/Configuration/js/module.js
  55. 136 0
      src/pineapple/modules/Configuration/module.html
  56. 12 0
      src/pineapple/modules/Configuration/module.info
  57. 13 0
      src/pineapple/modules/Configuration/module_icon.svg
  58. 105 0
      src/pineapple/modules/Dashboard/api/module.php
  59. 68 0
      src/pineapple/modules/Dashboard/js/module.js
  60. 157 0
      src/pineapple/modules/Dashboard/module.html
  61. 12 0
      src/pineapple/modules/Dashboard/module.info
  62. 15 0
      src/pineapple/modules/Dashboard/module_icon.svg
  63. 226 0
      src/pineapple/modules/Filters/api/module.php
  64. 141 0
      src/pineapple/modules/Filters/js/module.js
  65. 93 0
      src/pineapple/modules/Filters/module.html
  66. 12 0
      src/pineapple/modules/Filters/module.info
  67. 23 0
      src/pineapple/modules/Filters/module_icon.svg
  68. 46 0
      src/pineapple/modules/Help/api/module.php
  69. 54 0
      src/pineapple/modules/Help/files/debug
  70. 135 0
      src/pineapple/modules/Help/files/dumpscan.php
  71. 61 0
      src/pineapple/modules/Help/js/module.js
  72. 418 0
      src/pineapple/modules/Help/module.html
  73. 12 0
      src/pineapple/modules/Help/module.info
  74. 12 0
      src/pineapple/modules/Help/module_icon.svg
  75. 123 0
      src/pineapple/modules/Logging/api/module.php
  76. 193 0
      src/pineapple/modules/Logging/js/module.js
  77. 104 0
      src/pineapple/modules/Logging/module.html
  78. 12 0
      src/pineapple/modules/Logging/module.info
  79. 25 0
      src/pineapple/modules/Logging/module_icon.svg
  80. 196 0
      src/pineapple/modules/ModuleManager/api/module.php
  81. 128 0
      src/pineapple/modules/ModuleManager/js/module.js
  82. 121 0
      src/pineapple/modules/ModuleManager/module.html
  83. 11 0
      src/pineapple/modules/ModuleManager/module.info
  84. 15 0
      src/pineapple/modules/ModuleManager/module_icon.svg
  85. 50 0
      src/pineapple/modules/Networking/api/AccessPoint.php
  86. 173 0
      src/pineapple/modules/Networking/api/ClientMode.php
  87. 96 0
      src/pineapple/modules/Networking/api/Interfaces.php
  88. 255 0
      src/pineapple/modules/Networking/api/module.php
  89. 473 0
      src/pineapple/modules/Networking/js/module.js
  90. 375 0
      src/pineapple/modules/Networking/module.html
  91. 12 0
      src/pineapple/modules/Networking/module.info
  92. 16 0
      src/pineapple/modules/Networking/module_icon.svg
  93. 110 0
      src/pineapple/modules/Notes/api/module.php
  94. 44 0
      src/pineapple/modules/Notes/js/module.js
  95. 114 0
      src/pineapple/modules/Notes/module.html
  96. 12 0
      src/pineapple/modules/Notes/module.info
  97. 14 0
      src/pineapple/modules/Notes/module_icon.svg
  98. 269 0
      src/pineapple/modules/PineAP/api/PineAPHelper.php
  99. 892 0
      src/pineapple/modules/PineAP/api/module.php
  100. 22 0
      src/pineapple/modules/PineAP/executable/executable

+ 3 - 0
src/pineapple/.gitignore

@@ -0,0 +1,3 @@
+.DS_Store
+sftp-config.json
+.idea/

+ 244 - 0
src/pineapple/api/API.php

@@ -0,0 +1,244 @@
+<?php namespace pineapple;
+
+require_once('DatabaseConnection.php');
+
+class API
+{
+
+    private $request;
+    private $response;
+    private $error;
+    private $dbConnection;
+    const DATABASE = "/etc/pineapple/pineapple.db";
+
+    /**
+     * The constructor parses the JSON data from PHP's input.
+     * Notify the user of errors.
+     */
+    public function __construct()
+    {
+        $this->request = @json_decode(file_get_contents('php://input'));
+        if (json_last_error() !== JSON_ERROR_NONE) {
+            $this->error = 'Invalid JSON';
+        }
+        $this->dbConnection = new DatabaseConnection(self::DATABASE);
+        $this->setCSRFToken();
+    }
+
+    public function setCSRFToken()
+    {
+        if (session_status() == PHP_SESSION_NONE) {
+            session_start();
+        }
+        if (!isset($_SESSION['XSRF-TOKEN'])) {
+            $_SESSION['XSRF-TOKEN'] = sha1(session_id() . openssl_random_pseudo_bytes(16));
+        }
+        if (!isset($_COOKIE['XSRF-TOKEN']) || $_COOKIE['XSRF-TOKEN'] !== $_SESSION['XSRF-TOKEN']) {
+            setcookie('XSRF-TOKEN', $_SESSION['XSRF-TOKEN'], 0, '/', '', false, false);
+        }
+    }
+
+    /**
+     * Checks if the user is currently authenticated
+     * @return boolean
+     */
+    public function authenticated()
+    {
+        if (isset($_SESSION['logged_in']) && $_SESSION['logged_in'] === true) {
+            if (isset($_SERVER['HTTP_X_XSRF_TOKEN']) && $_SERVER['HTTP_X_XSRF_TOKEN'] === $_SESSION['XSRF-TOKEN']) {
+                return true;
+            } else {
+                $this->error = "Invalid CSRF token";
+                return false;
+            }
+        } elseif (isset($this->request->system) && $this->request->system === 'authentication') {
+            if (isset($this->request->action) && $this->request->action === 'login') {
+                return true;
+            }
+        } elseif (isset($this->request->system) && $this->request->system === 'setup') {
+            if (file_exists('/etc/pineapple/setupRequired')) {
+                return true;
+            }
+        } elseif (isset($this->request->apiToken)) {
+            $token = $this->request->apiToken;
+            $result = $this->dbConnection->query("SELECT token FROM api_tokens WHERE token='%s';", $token);
+            if (!empty($result) && isset($result[0]["token"]) && $result[0]["token"] === $token) {
+                return true;
+            }
+        }
+        if (file_exists('/etc/pineapple/setupRequired')) {
+            $this->response = array('error' => 'Not Authenticated', 'setupRequired' => true);
+        } else {
+            $this->error = "Not Authenticated";
+        }
+        return false;
+    }
+
+    /**
+     * Routes the API request to the appropriate modules
+     * @return void
+     */
+    public function route()
+    {
+        if (isset($this->request->system) && !empty($this->request->system)) {
+            $this->routeToSystem($this->request->system);
+        } elseif (isset($this->request->module) && !empty($this->request->module)) {
+            $this->routeToModule($this->request->module);
+        } else {
+            $this->error = "Invalid request";
+        }
+    }
+
+    /**
+     * Function to finalize API and form the JSON return string
+     * @return String JSON String
+     */
+    public function finalize()
+    {
+        if ($this->error) {
+            return ")]}',\n" . json_encode(array("error" => $this->error));
+        } elseif ($this->response) {
+            return ")]}',\n" . json_encode($this->response);
+        }
+        return "";
+    }
+
+    /**
+    * Function to lazy load a module class given a module name
+    * @param String $moduleName The module Name
+    * @return String The class of the module just loaded
+    */
+    private function lazyLoad($moduleName)
+    {
+        require_once("Module.php");
+        require_once("SystemModule.php");
+
+        $found = false;
+        $moduleClass = "";
+
+        foreach (glob('/pineapple/modules/*') as $moduleFolder) {
+            if (str_replace('/pineapple/modules/', '', $moduleFolder) === $moduleName) {
+                $found = true;
+                require_once("$moduleFolder/api/module.php");
+                $moduleClass = "pineapple\\{$moduleName}";
+            }
+        }
+
+        if (!$found) {
+            $this->error = "Module {$moduleName} does not exist or is defined incorrectly";
+            return null;
+        }
+        if (!class_exists($moduleClass)) {
+            $this->error = "The class {$moduleClass} does not exist in {$moduleFolder}";
+            return null;
+        }
+
+        return $moduleClass;
+    }
+
+    /**
+     * Function to route a module request to
+     * it's appropriate module.
+     * @param  String $moduleName The module Name
+     * @return void
+     */
+    private function routeToModule($moduleName)
+    {
+        session_write_close();
+
+        $moduleClass = $this->lazyLoad($moduleName);
+        if ($moduleClass === null) {
+            return;
+        }
+
+        $module = new $moduleClass($this->request, $moduleClass);
+        $module->route();
+        $this->response = $module->getResponse();
+    }
+
+    /**
+     * Function to route a system request to the
+     * appropriate component.
+     * @param  String $systemRequest The system request
+     * @return void
+     */
+    private function routeToSystem($systemRequest)
+    {
+        require_once("APIModule.php");
+        $systemComponent = null;
+        switch ($systemRequest) {
+            case 'notifications':
+                require_once("Notifications.php");
+                $systemComponent = new Notifications($this->request);
+                break;
+
+            case 'modules':
+                require_once("Modules.php");
+                $systemComponent = new Modules($this->request);
+                break;
+
+            case 'authentication':
+                require_once("Authentication.php");
+                $systemComponent = new Authentication($this->request);
+                break;
+            case 'setup':
+                if (file_exists('Setup.php')) {
+                    require_once('Setup.php');
+                    $systemComponent = new Setup($this->request);
+                    break;
+                }
+        }
+
+        if ($systemComponent !== null) {
+            $systemComponent->route();
+            $this->response = $systemComponent->getResponse();
+        }
+    }
+
+    private function handleDownload()
+    {
+        $this->dbConnection->exec("CREATE TABLE IF NOT EXISTS downloads (token VARCHAR NOT NULL, file VARCHAR NOT NULL, time timestamp default (strftime('%s', 'now')));");
+        $this->dbConnection->exec("DELETE FROM downloads WHERE time < (strftime('%s', 'now')-30)");
+        $result = $this->dbConnection->query('SELECT file from downloads WHERE token="%s";', $_GET['download']);
+        if (isset($result[0])) {
+            $this->streamFile($result[0]['file']);
+        } else {
+            echo "Invalid download token.";
+        }
+        exit();
+    }
+
+    private function streamFile($file)
+    {
+        if (file_exists($file)) {
+            header('Content-Description: File Transfer');
+            header('Content-Type: application/octet-stream');
+            header('Content-Disposition: attachment; filename="'.basename($file).'"');
+            header('Expires: 0');
+            header('Cache-Control: no-cache, no-store, must-revalidate');
+            header('Pragma: public');
+            header('Content-Length: ' . filesize($file));
+            readfile($file);
+        } else {
+            echo "Invalid file.";
+        }
+        exit();
+    }
+
+    /**
+    * Does magic
+    */
+    public function magic()
+    {
+        if (isset($_GET['download'])) {
+            $this->handleDownload();
+        } else {
+            if ($this->authenticated()) {
+                $this->route();
+            }
+            return $this->finalize();
+        }
+
+        return true;
+    }
+}

+ 26 - 0
src/pineapple/api/APIModule.php

@@ -0,0 +1,26 @@
+<?php namespace pineapple;
+
+abstract class APIModule
+{
+    protected $request;
+    protected $response;
+    protected $error;
+
+    abstract public function route();
+
+    public function __construct($request)
+    {
+        $this->request = $request;
+    }
+
+    public function getResponse()
+    {
+        if (empty($this->error) && !empty($this->response)) {
+            return $this->response;
+        } elseif (empty($this->error) && empty($this->response)) {
+            return array('error' => 'API returned empty response');
+        } else {
+            return array('error' => $this->error);
+        }
+    }
+}

+ 134 - 0
src/pineapple/api/Authentication.php

@@ -0,0 +1,134 @@
+<?php namespace pineapple;
+
+require_once('DatabaseConnection.php');
+
+class Authentication extends APIModule
+{
+    private $dbConnection;
+
+    const DATABASE = "/etc/pineapple/pineapple.db";
+
+    public function __construct($request)
+    {
+        parent::__construct($request);
+        $this->dbConnection = new DatabaseConnection(self::DATABASE);
+        $this->dbConnection->exec("CREATE TABLE IF NOT EXISTS api_tokens (token VARCHAR NOT NULL, name VARCHAR NOT NULL);");
+    }
+
+    public function getApiTokens()
+    {
+        $this->response = array("tokens" => $this->dbConnection->query("SELECT token,name FROM api_tokens;"));
+    }
+
+    public function checkApiToken()
+    {
+        if (isset($this->request->token)) {
+            $token = $this->request->token;
+            $result = $this->dbConnection->query("SELECT token FROM api_tokens WHERE token='%s';", $token);
+            if (!empty($result) && isset($result[0]["token"]) && $result[0]["token"] === $token) {
+                $this->response = array("valid" => true);
+            }
+        }
+        $this->response = array("valid" => false);
+    }
+
+    public function addApiToken()
+    {
+        if (isset($this->request->token) && isset($this->request->name)) {
+            $token = $this->request->token;
+            $name = $this->request->name;
+            $this->dbConnection->exec("INSERT INTO api_tokens(token, name) VALUES('%s','%s');", $token, $name);
+            $this->response = array("success" => true);
+        } else {
+            $this->error = "Missing token or name";
+        }
+    }
+
+    private function login()
+    {
+        if (isset($this->request->username) && isset($this->request->password)) {
+            if ($this->verifyPassword($this->request->password)) {
+                $_SESSION['logged_in'] = true;
+                $this->response = array("logged_in" => true);
+                if (!isset($this->request->time)) {
+                    return;
+                }
+                $epoch = intval($this->request->time);
+                if (is_int($epoch) && $epoch > 1) {
+                    exec('date -s @' . $epoch);
+                }
+                return;
+            }
+        }
+
+        $this->response = array("logged_in" => false);
+    }
+
+    private function verifyPassword($password)
+    {
+        $shadowContents = file_get_contents('/etc/shadow');
+        $rootArray = explode(':', explode('root:', $shadowContents)[1]);
+        $rootPass = $rootArray[0];
+        if ($rootPass != null && !empty($rootPass) && gettype($rootPass) === "string") {
+            return hash_equals($rootPass, crypt($password, $rootPass));
+        }
+        return false;
+    }
+
+    private function logout()
+    {
+        $this->response = array("logged_in" => false);
+        unset($_COOKIE['XSRF-TOKEN']);
+        setcookie('XSRF-TOKEN', '', time()-3600);
+        unset($_SESSION['XSRF-TOKEN']);
+        unset($_SESSION['logged_in']);
+        session_destroy();
+    }
+
+    private function checkAuth()
+    {
+        if (isset($_SESSION['logged_in']) && $_SESSION['logged_in'] === true) {
+            $this->response = array("authenticated" => true);
+        } else {
+            if (file_exists("/etc/pineapple/setupRequired")) {
+                $this->response = array("error" => "Not Authenticated", "setupRequired" => true);
+            } else {
+                $this->response = array("error" => "Not Authenticated");
+            }
+        }
+    }
+
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'login':
+                $this->login();
+                break;
+
+            case 'logout':
+                $this->logout();
+                break;
+
+            case 'checkAuth':
+                $this->checkAuth();
+                break;
+
+            case 'checkApiToken':
+                $this->checkApiToken();
+                break;
+
+            case 'addApiToken':
+                $this->addApiToken();
+                break;
+
+            case 'getApiTokens':
+                $this->getApiTokens();
+                break;
+
+            default:
+                $this->error = "Unknown action";
+        }
+        
+        session_write_close();
+    }
+}

+ 103 - 0
src/pineapple/api/DatabaseConnection.php

@@ -0,0 +1,103 @@
+<?php namespace pineapple;
+
+class DatabaseConnection
+{
+    private $databaseFile;
+    private $dbConnection;
+    public $error;
+
+    public function __construct($databaseFile)
+    {
+        $this->error = array();
+        $this->databaseFile = $databaseFile;
+        try {
+            $this->dbConnection = new \SQLite3($this->databaseFile);
+            $this->dbConnection->busyTimeout(20000);
+        } catch (\Exception $e) {
+            $this->error["databaseConnectionError"] = $e->getMessage();
+        }
+    }
+
+    public function strError()
+    {
+        foreach ($this->error as $errorType => $errorMessage) {
+            switch ($errorType) {
+                case 'databaseConnectionError':
+                    return "Could not connect to database: $errorMessage";
+                    break;
+                case 'databaseQueryError':
+                    return "Could not execute query: $errorMessage";
+                    break;
+                case 'databaseExecutionError':
+                    return "Could not execute query: $errorMessage";
+                    break;
+                default:
+                    return "Unknown database error";
+            }
+        }
+
+        return true;
+    }
+
+    public function getDatabaseFile()
+    {
+        return $this->databaseFile;
+    }
+
+    public function getDbConnection()
+    {
+        return $this->dbConnection;
+    }
+
+    public static function formatQuery(...$query)
+    {
+        $query = $query[0];
+        $sqlQuery = $query[0];
+        $sqlParameters = array_slice($query, 1);
+        if (empty($sqlParameters)) {
+            return $sqlQuery;
+        }
+        for ($i = 0; $i < count($sqlParameters); ++$i) {
+            if (gettype($sqlParameters[$i]) === "string") {
+                $escaped = \SQLite3::escapeString($sqlParameters[$i]);
+                $sqlParameters[$i] = $escaped;
+            }
+        }
+        $safeQuery = vsprintf($sqlQuery, $sqlParameters);
+        return $safeQuery;
+    }
+
+    public function query(...$query)
+    {
+        $safeQuery = DatabaseConnection::formatQuery($query);
+        $result = $this->dbConnection->query($safeQuery);
+        if (!$result) {
+            $this->error['databaseQueryError'] = $this->dbConnection->lastErrorMsg();
+            return $this->error;
+        }
+        $resultArray = array();
+        while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
+            array_push($resultArray, $row);
+        }
+        return $resultArray;
+    }
+
+    public function exec(...$query)
+    {
+        $safeQuery = DatabaseConnection::formatQuery($query);
+        try {
+            $result = $this->dbConnection->exec($safeQuery);
+        } catch (\Exception $e) {
+            $this->error['databaseExecutionError'] = $e;
+            return $this->error;
+        }
+        return array('success' => $result);
+    }
+
+    public function __destruct()
+    {
+        if ($this->dbConnection) {
+            $this->dbConnection->close();
+        }
+    }
+}

+ 132 - 0
src/pineapple/api/Module.php

@@ -0,0 +1,132 @@
+<?php namespace pineapple;
+
+/**
+ * This class will contain the base code which all modules
+ * must extend.
+ */
+
+abstract class Module
+{
+    protected $request;
+    protected $response;
+    protected $moduleClass;
+    protected $error;
+    protected $streamFunction;
+
+    abstract public function route();
+
+    public function __construct($request, $moduleClass)
+    {
+        $this->request = $request;
+        $this->moduleClass = $moduleClass;
+        $this->error = '';
+    }
+
+    public function getResponse()
+    {
+        if (empty($this->error) && !empty($this->response)) {
+            return $this->response;
+        } elseif (!empty($this->streamFunction)) {
+            header('Content-Type: text/plain');
+            $this->streamFunction->__invoke();
+            return false;
+        } elseif (empty($this->error) && empty($this->response)) {
+            return array('error' => 'Module returned empty response');
+        } else {
+            return array('error' => $this->error);
+        }
+    }
+
+    public function execBackground($command)
+    {
+        return \helper\execBackground($command);
+    }
+
+    protected function isSDAvailable()
+    {
+        return \helper\isSDAvailable();
+    }
+
+    protected function sdReaderPresent() {
+        return \helper\sdReaderPresent();
+    }
+
+    protected function sdCardPresent() {
+        return \helper\sdCardPresent();
+    }
+
+    protected function checkRunning($processName)
+    {
+        return \helper\checkRunning($processName);
+    }
+
+    protected function checkRunningFull($processString) {
+        return \helper\checkRunningFull($processString);
+    }
+
+    public function uciGet($uciString)
+    {
+        return \helper\uciGet($uciString);
+    }
+
+    public function uciSet($settingString, $value)
+    {
+       \helper\uciSet($settingString, $value);
+    }
+
+    public function uciAddList($settingString, $value)
+    {
+       \helper\uciAddList($settingString, $value);
+    }
+
+    protected function downloadFile($file)
+    {
+        return \helper\downloadFile($file);
+    }
+
+    protected function getFirmwareVersion()
+    {
+        return \helper\getFirmwareVersion();
+    }
+
+    protected function getDevice()
+    {
+        return \helper\getDevice();
+    }
+
+    protected function getMacFromInterface($interface)
+    {
+        return \helper\getMacFromInterface($interface);
+    }
+
+    protected function udsSend($path, $message)
+    {
+        return \helper\udsSend($path, $message);
+    }
+
+    protected function dgramUdsSend($path, $message)
+    {
+        return \helper\dgramUdsSend($path, $message);
+    }
+
+    protected function installDependency($dependencyName, $installToSD = false)
+    {
+        if ($installToSD && !$this->isSDAvailable()) {
+            return false;
+        }
+
+        $destination = $installToSD ? '--dest sd' : '';
+        $dependencyName = escapeshellarg($dependencyName);
+
+        if (!$this->checkDependency($dependencyName)) {
+            exec("opkg update");
+            exec("opkg install {$dependencyName} {$destination}");
+        }
+        return $this->checkDependency($dependencyName);
+    }
+
+    protected function checkDependency($dependencyName)
+    {
+        return \helper\checkDependency($dependencyName);
+    }
+}

+ 77 - 0
src/pineapple/api/Modules.php

@@ -0,0 +1,77 @@
+<?php namespace pineapple;
+
+class Modules extends APIModule
+{
+    private $modules;
+
+    public function __construct($request)
+    {
+        Parent::__construct($request);
+        $this->modules = array('systemModules' => array(), 'userModules' => array());
+    }
+
+    public function getModules()
+    {
+        require_once('DatabaseConnection.php');
+
+        $dir = scandir("../modules");
+        if ($dir == false) {
+            $this->error = "Unable to access modules directory";
+            return $this->modules;
+        }
+
+        natcasesort($dir);
+
+        foreach ($dir as $moduleFolder) {
+            if ($moduleFolder[0] === '.') {
+                continue;
+            }
+
+            $modulePath = "../modules/{$moduleFolder}";
+
+            if (!file_exists("{$modulePath}/module.info")) {
+                continue;
+            }
+
+            $moduleInfo = @json_decode(file_get_contents("{$modulePath}/module.info"));
+            if (json_last_error() !== JSON_ERROR_NONE) {
+                continue;
+            }
+
+            $moduleTitle = (isset($moduleInfo->title)) ? $moduleInfo->title : $moduleFolder;
+
+            if (file_exists("$modulePath/module_icon.svg")) {
+                $module = array("name" => $moduleFolder, "title" => $moduleTitle, "icon" => "/modules/${moduleFolder}/module_icon.svg");
+            } elseif (file_exists("$modulePath/module_icon.png")) {
+                $module = array("name" => $moduleFolder, "title" => $moduleTitle, "icon" => "/modules/${moduleFolder}/module_icon.png");
+            } else {
+                $module = array("name" => $moduleFolder, "title" => $moduleTitle, "icon" => null);
+            }
+
+            if (isset($moduleInfo->system)) {
+                if (!isset($moduleInfo->index)) {
+                    continue;
+                }
+                $this->modules['systemModules'][$moduleInfo->index] = $module;
+            } elseif (isset($moduleInfo->cliOnly)) {
+                continue;
+            } else {
+                array_push($this->modules['userModules'], $module);
+            }
+        }
+
+        return $this->modules;
+    }
+
+    public function route()
+    {
+        switch ($this->request->action) {
+            case "getModuleList":
+                $this->getModules();
+                $this->response = array('modules' => $this->modules);
+                break;
+            default:
+                $this->error = "Unknown action: " . $this->request->action;
+        }
+    }
+}

+ 63 - 0
src/pineapple/api/Notifications.php

@@ -0,0 +1,63 @@
+<?php namespace pineapple;
+
+require_once('DatabaseConnection.php');
+
+class Notifications extends APIModule
+{
+
+    private $notifications;
+    private $dbConnection;
+    const DATABASE = "/etc/pineapple/pineapple.db";
+
+    public function __construct($request)
+    {
+        parent::__construct($request);
+        $this->dbConnection = new DatabaseConnection(self::DATABASE);
+        if (!empty($this->dbConnection->error)) {
+            $this->error = $this->dbConnection->strError();
+            return;
+        }
+        $this->notifications = array();
+        $this->dbConnection->exec("CREATE TABLE IF NOT EXISTS notifications (message VARCHAR NOT NULL, time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP);");
+        if (!empty($this->dbConnection->error)) {
+            $this->error = $this->dbConnection->strError();
+        }
+    }
+
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'listNotifications':
+                $this->response = $this->getNotifications();
+                break;
+            case 'addNotification':
+                $this->response = $this->addNotification($this->request->message);
+                break;
+            case 'clearNotifications':
+                $this->response = $this->clearNotifications();
+                break;
+            default:
+                $this->error = "Unknown action: " . $this->request->action;
+        }
+    }
+
+    public function addNotification($message)
+    {
+        $result = $this->dbConnection->exec("INSERT INTO notifications (message) VALUES('%s');", $message);
+        return $result;
+    }
+
+    public function getNotifications()
+    {
+        $result = $this->dbConnection->query("SELECT message,time from notifications ORDER BY time DESC;");
+        $this->notifications = $result;
+        return $this->notifications;
+    }
+
+    public function clearNotifications()
+    {
+        $result = $this->dbConnection->exec('DELETE FROM notifications;');
+        unset($this->notifications);
+        return $result;
+    }
+}

+ 238 - 0
src/pineapple/api/Setup.php

@@ -0,0 +1,238 @@
+<?php namespace pineapple;
+
+class Setup extends APIModule
+{
+    private function changePassword()
+    {
+        if ($this->request->rootPassword !== $this->request->confirmRootPassword) {
+            $this->error = 'The root passwords do not match.';
+            return false;
+        }
+        $new = $this->request->rootPassword;
+        $shadow_file = file_get_contents('/etc/shadow');
+        $root_array = explode(":", explode("\n", $shadow_file)[0]);
+        $salt = '$1$' . explode('$', $root_array[1])[2] . '$';
+        $new = crypt($new, $salt);
+        $find = implode(":", $root_array);
+        $root_array[1] = $new;
+        $replace = implode(":", $root_array);
+
+        $shadow_file = str_replace($find, $replace, $shadow_file);
+        file_put_contents("/etc/shadow", $shadow_file);
+        return true;
+    }
+
+    private function checkButtonStatus()
+    {
+        $buttonPressed = false;
+        $bootStatus = false;
+        if (file_exists('/tmp/button_setup')) {
+            $buttonPressed = true;
+        }
+        if (!file_exists('/etc/pineapple/init')) {
+            $bootStatus = true;
+        }
+        $this->response = array('buttonPressed' => $buttonPressed, 'booted' => $bootStatus);
+        return $buttonPressed;
+    }
+
+    private function getChanges()
+    {
+        if (file_exists("/etc/pineapple/changes")) {
+            $changes = file_get_contents("/etc/pineapple/changes");
+            $version = trim(file_get_contents('/etc/pineapple/pineapple_version'));
+            $this->response = array('changes' => $changes, 'fwversion' => $version);
+        } else {
+            $this->response = array('changes' => NULL);
+        }
+        return true;
+    }
+
+    private function getDeviceName()
+    {
+        $device = \helper\getDevice();
+        if ($device == 'tetra') {
+            $this->response = array('device' => "tetra");
+        } elseif ($device == 'nano') {
+            $this->response = array('device' => "nano");
+        }
+        return true;
+    }
+
+    private function populateFields()
+    {
+        exec('cat /sys/class/ieee80211/phy0/macaddress|awk -F ":" \'{print $5""$6 }\'| tr a-z A-Z', $macOctets);
+        $this->response = array('openSSID' => "Pineapple_{$macOctets[0]}", 'hideOpenAP' => true);
+        return true;
+    }
+
+    private function setupWifi()
+    {
+        $managementSSID = $this->request->managementSSID;
+        $managementPass = $this->request->managementPass;
+        $hideManagementAP = $this->request->hideManagementAP;
+        $disableManagementAP = $this->request->disableManagementAP;
+        $openSSID = $this->request->openSSID;
+        $hideOpenAP = $this->request->hideOpenAP;
+        $countryCode = $this->request->countryCode;
+
+        if (strlen($managementSSID) < 1) {
+            $this->error = 'The Management SSID cannot be empty.';
+            return false;
+        }
+        if (strlen($openSSID) < 1) {
+            $this->error = 'The Open AP SSID cannot be empty.';
+            return false;
+        }
+        if ($managementPass !== $this->request->confirmManagementPass) {
+            $this->error = 'The WPA2 Passwords do not match.';
+            return false;
+        }
+        if (strlen($managementPass) < 8) {
+            $this->error = 'The WPA2 passwords must be at least 8 characters.';
+            return false;
+        }
+
+        $managementSSID = substr(escapeshellarg($managementSSID), 0, 32);
+        $openSSID = substr(escapeshellarg($openSSID), 0, 32);
+        $managementPass = escapeshellarg($managementPass);
+
+        exec('/sbin/wifi config > /etc/config/wireless');
+        exec("uci set wireless.@wifi-iface[1].ssid={$managementSSID}");
+        exec("uci set wireless.@wifi-iface[1].key={$managementPass}");
+        exec("uci set wireless.@wifi-iface[1].hidden={$hideManagementAP}");
+        exec("uci set wireless.@wifi-iface[1].disabled={$disableManagementAP}");
+        exec("uci set wireless.@wifi-iface[0].ssid={$openSSID}");
+        exec("uci set wireless.@wifi-iface[0].hidden={$hideOpenAP}");
+        exec("uci set wireless.radio0.country={$countryCode}");
+        exec("uci set wireless.radio1.country={$countryCode}");
+        exec('uci commit wireless');
+
+        return true;
+    }
+
+    private function enableSSH()
+    {
+        exec('echo "/etc/init.d/sshd enable" | at now');
+        exec('echo "/etc/init.d/sshd start" | at now');
+        $pid = explode("\n", exec('pgrep /usr/sbin/sshd'))[0];
+        if (is_numeric($pid) && intval($pid) > 0) {
+            return true;
+        }
+        return false;
+    }
+
+    private function restartWifi()
+    {
+        exec('echo "/sbin/wifi" | at now');
+    }
+
+    private function setupPineAP()
+    {
+        if ($this->request->macFilterMode === "Allow") {
+            exec('hostapd_cli -i wlan0 karma_mac_white');
+            exec('uci set pineap.@config[0].mac_filter=white');
+        } else {
+            exec('hostapd_cli -i wlan0 karma_mac_black');
+            exec('uci set pineap.@config[0].mac_filter=black');
+        }
+        if ($this->request->ssidFilterMode === "Allow") {
+            exec('hostapd_cli -i wlan0 karma_white');
+            exec('uci set pineap.@config[0].ssid_filter=white');
+        } else {
+            exec('hostapd_cli -i wlan0 karma_black');
+            exec('uci set pineap.@config[0].ssid_filter=black');
+        }
+        exec('uci commit pineap');
+    }
+
+    private function restartFirewall()
+    {
+        exec("/etc/init.d/firewall restart");
+    }
+
+    private function setupFirewall()
+    {
+        if ($this->request->WANSSHAccess) {
+            exec("uci set firewall.allowssh.enabled=1");
+            exec("uci commit firewall");
+        }
+
+        if ($this->request->WANUIAccess) {
+            exec("uci set firewall.allowui.enabled=1");
+            exec("uci commit firewall");
+        }
+    }
+
+    private function finalizeSetup()
+    {
+        $this->enableSSH();
+        $this->restartFirewall();
+        $this->restartWifi();
+        @unlink('/etc/pineapple/setupRequired');
+        @unlink('/pineapple/api/Setup.php');
+        $timeZone = $this->request->timeZone;
+        exec("echo {$timeZone} > /etc/TZ");
+        exec("uci set system.@system[0].timezone={$timeZone}");
+        exec("uci commit");
+        exec('killall blink');
+        exec('led reset');
+        exec('/bin/rm -rf /pineapple/modules/Setup');
+    }
+
+    public function performSetup()
+    {
+        if (!$this->checkButtonStatus()) {
+            $this->error = "Not verified.";
+            return false;
+        }
+
+        if ($this->request->eula !== true || $this->request->license !== true) {
+            $this->error = "Please accept the EULA and Software License.";
+            return false;
+        }
+
+        if ($this->request->macFilterMode !== "Allow" && $this->request->macFilterMode !== "Deny") {
+            $this->error = "Please choose a setting for the Client Filter.";
+            return false;
+        }
+
+        if ($this->request->ssidFilterMode !== "Allow" && $this->request->ssidFilterMode !== "Deny") {
+            $this->error = "Please choose a setting for the SSID Filter.";
+            return false;
+        }
+
+
+        if ($this->changePassword() && $this->setupWifi()) {
+            $this->setupPineAP();
+            $this->setupFirewall();
+            $this->finalizeSetup();
+        }
+
+        return true;
+    }
+
+    public function route()
+    {
+        @session_write_close();
+        if (file_exists('/etc/pineapple/setupRequired')) {
+            switch ($this->request->action) {
+                case 'checkButtonStatus':
+                    $this->checkButtonStatus();
+                    break;
+                case 'getChanges':
+                    $this->getChanges();
+                    break;
+                case 'getDeviceName':
+                    $this->getDeviceName();
+                    break;
+                case 'populateFields':
+                    $this->populateFields();
+                    break;
+                case 'performSetup':
+                    $this->performSetup();
+                    break;
+            }
+        }
+    }
+}

+ 25 - 0
src/pineapple/api/SystemModule.php

@@ -0,0 +1,25 @@
+<?php namespace pineapple;
+
+abstract class SystemModule extends Module
+{
+    protected function changePassword($current, $new)
+    {
+        $shadow_file = file_get_contents('/etc/shadow');
+        $root_array = explode(":", explode("\n", $shadow_file)[0]);
+        $salt = '$1$'.explode('$', $root_array[1])[2].'$';
+        $current_shadow_pass = $salt.explode('$', $root_array[1])[3];
+        $current = crypt($current, $salt);
+        $new = crypt($new, $salt);
+        if ($current_shadow_pass == $current) {
+            $find = implode(":", $root_array);
+            $root_array[1] = $new;
+            $replace = implode(":", $root_array);
+
+            $shadow_file = str_replace($find, $replace, $shadow_file);
+            file_put_contents("/etc/shadow", $shadow_file);
+
+            return true;
+        }
+        return false;
+    }
+}

+ 8 - 0
src/pineapple/api/index.php

@@ -0,0 +1,8 @@
+<?php namespace pineapple;
+
+header('Content-Type: application/json');
+
+require_once('pineapple.php');
+require_once('API.php');
+$api = new API();
+echo $api->magic();

+ 142 - 0
src/pineapple/api/pineapple.php

@@ -0,0 +1,142 @@
+<?php namespace helper;
+
+function execBackground($command)
+{
+	exec("echo \"{$command}\" | /usr/bin/at now", $var);
+	return $var;
+}
+
+function checkDependency($dependencyName)
+{
+    exec("/usr/bin/which $dependencyName", $output);
+    if (trim($output[0]) == "") {
+        return false;
+    } else {
+        return true;
+    }
+}
+
+function isSDAvailable()
+{
+    $output = exec('/bin/mount | /bin/grep "on /sd" -c');
+    if ($output >= 1) {
+        return true;
+    } else {
+        return false;
+    }
+}
+
+function sdReaderPresent() {
+	return file_exists('/sd');
+}
+
+function sdCardPresent() {
+	return !file_exists('/sd/NO_SD');
+}
+
+function checkRunning($processName)
+{
+	$processName = escapeshellarg($processName);
+	exec("/usr/bin/pgrep {$processName}", $output);
+	return count($output) > 0;
+}
+
+function checkRunningFull($processString) {
+	$processString = escapeshellarg($processString);
+	exec("/usr/bin/pgrep -f {$processString}", $output);
+	return count($output) > 0;
+}
+
+function udsSend($path, $message)
+{
+	$sock = stream_socket_client("unix://{$path}", $errno, $errstr);
+	fwrite($sock, $message);
+	fclose($sock);
+	return true;
+}
+
+function dgramUdsSend($path, $message)
+{
+	$sock = NULL;
+	if(!($sock = socket_create(AF_UNIX, SOCK_DGRAM, 0)))
+	{
+	    return false;
+	}
+	socket_sendto($sock, $message, strlen($message), 0, $path);
+	return true;
+}
+
+function uciGet($uciString)
+{
+	$uciString = escapeshellarg($uciString);
+	$result = exec("uci get {$uciString}");
+
+	$result = ($result === "1") ? true : $result;
+	$result = ($result === "0") ? false : $result;
+
+	return $result;
+}
+
+function uciSet($settingString, $value)
+{
+	$settingString = escapeshellarg($settingString);
+	if (!empty($value)) {
+		$value = escapeshellarg($value);
+	}
+
+	if ($value === "''" || $value === "") {
+		$value = "'0'";
+	}
+
+	exec("uci set {$settingString}={$value}");
+	exec("uci commit {$settingString}");
+}
+
+function uciAddList($settingString, $value)
+{
+	$settingString = escapeshellarg($settingString);
+	if (!empty($value)) {
+		$value = escapeshellarg($value);
+	}
+
+	if ($value === "''") {
+		$value = "'0'";
+	}
+
+	exec("uci add_list {$settingString}={$value}");
+	exec("uci commit {$settingString}");
+}
+
+function downloadFile($file)
+{
+	$token = hash('sha256', $file . time());
+
+	require_once('DatabaseConnection.php');
+	$database = new \pineapple\DatabaseConnection("/etc/pineapple/pineapple.db");
+	$database->exec("CREATE TABLE IF NOT EXISTS downloads (token VARCHAR NOT NULL, file VARCHAR NOT NULL, time timestamp default (strftime('%s', 'now')));");
+	$database->exec("INSERT INTO downloads (token, file) VALUES ('%s', '%s')", $token, $file);
+
+	return $token;
+}
+
+function getFirmwareVersion()
+{
+	return trim(file_get_contents('/etc/pineapple/pineapple_version'));
+}
+
+function getDevice()
+{
+	$data = file_get_contents('/proc/cpuinfo');
+	if (preg_match('/NANO/', $data)) {
+		return 'nano';
+	} elseif (preg_match('/TETRA/', $data)) {
+		return 'tetra';
+	}
+	return 'unknown';
+}
+
+function getMacFromInterface($interface)
+{
+	$interface = escapeshellarg($interface);
+	return trim(exec("ifconfig {$interface} | grep HWaddr | awk '{print $5}'"));
+}

文件差异内容过多而无法显示
+ 4 - 0
src/pineapple/css/bootstrap.min.css


+ 223 - 0
src/pineapple/css/main.css

@@ -0,0 +1,223 @@
+.module-table > * > tr:nth-child(even) {
+    background-color: #f5f5f5;
+}
+
+.default-cursor {
+    cursor: pointer;
+}
+
+.margin-bottom {
+    margin-bottom: 5px;
+}
+
+.margin-top {
+    margin-top: 5px;
+}
+
+.truncated {
+    text-overflow: ellipsis;
+    overflow: hidden
+}
+
+.button-throbber {
+    height: 18px;
+}
+
+.uppercase {
+    text-transform: uppercase;
+}
+
+.table-layout-fixed {
+    table-layout: fixed;
+}
+
+.module-icon {
+     display: inline;
+     height: 24px;
+     width: 24px;
+}
+
+.fixed-addon-width {
+    min-width: 70px;
+    text-align: left;
+}
+
+.fixed-addon-width-2 {
+    min-width: 90px;
+    text-align: left;
+}
+
+.fixed-addon-width-3 {
+    min-width: 110px;
+    text-align: left;
+}
+
+.fixed-width-200 {
+    min-width: 200px;
+}
+
+.caret-reversed {
+    border-top-width: 0;
+    border-bottom: 4px solid #000;
+}
+
+.image-small-18 {
+    height: 18px;
+}
+
+.center-text {
+    text-align: center;
+}
+
+.scrollable-pre {
+    overflow: auto;
+    word-wrap: normal;
+    white-space: pre;
+}
+
+.log-pre {
+    max-height: 300px;
+}
+
+.btn-fixed-length {
+    width: 70px;
+}
+
+.title-message {
+    margin-left: 10px;
+    padding-left: 5px;
+    padding-right: 5px;
+    height: 9px;
+    border-radius: 3px;
+}
+
+.padding-left {
+    margin-left: 10px;
+}
+
+.select-inline {
+    font-weight: normal;
+}
+
+body {
+    background-color: #f8f8f8;
+}
+
+.logout {
+    cursor: pointer;
+}
+
+.module-nav li a {
+    margin-left: 30px;
+}
+
+.module-nav li:hover {
+    background-color: #eee;
+}
+
+.sidebar .sidebar-nav.navbar-collapse {
+    padding-right: 0;
+    padding-left: 0;
+}
+
+.sidebar ul li {
+    cursor: pointer;
+    border-bottom: 1px solid #e7e7e7;
+}
+
+.sidebar .active {
+    background-color: #eee;
+}
+
+@media(min-width:768px) {
+    .sidebar {
+        z-index: 1;
+        position: absolute;
+        width: 250px;
+        margin-top: 51px;
+    }
+
+    .module-content {
+        position: inherit;
+        margin: 0 0 0 250px;
+        padding: 15px 30px;
+        border-left: 1px solid #e7e7e7;
+    }
+
+    .navbar-top-links {
+        margin-left: 10px;
+    }
+}
+
+.navbar-top-links {
+    margin-right: 5px;
+    margin-left: 10px;
+}
+
+.navbar-top-links li {
+    display: inline-block;
+}
+
+.navbar {
+    margin-bottom: 0;
+}
+
+.navbar-top-links .dropdown-menu li {
+    display: block;
+}
+
+.module-content {
+    padding: 15px 15px;
+    background-color: #fff;
+}
+
+.pointer {
+    cursor: pointer;
+}
+
+.dropdown-menu {
+    cursor: pointer;
+}
+
+.dropdown-menu-top {
+    max-height: 300px !important;
+    overflow-y: auto !important;
+}
+
+@media(max-width:768px) {
+    .dropdown-menu-top {
+        margin: auto !important;
+        position: absolute !important;
+        background-color: #fff !important;
+        word-wrap: break-word !important;
+        border: 1px solid #ccc !important;
+        width: 300px !important;
+        max-width: 300px !important;
+    }
+    .dropdown-menu-top li a {
+        white-space: normal !important;
+    }
+
+    .dropdown-menu-logout {
+        max-width: 50px !important;
+    }
+}
+
+.login-logo {
+    margin: 0 auto;
+    padding-bottom: 10px;
+    max-width: 150px;
+}
+
+.brand-logo {
+    content: url('/img/logo.png');
+    max-height: 30px;
+    padding-bottom: 5px;
+}
+
+.brand-text::after {
+    content: "WiFi Pineapple";
+    padding-top: 3px;
+    padding-left: 5px;
+    float: right;
+}

+ 73 - 0
src/pineapple/html/clone-modal.html

@@ -0,0 +1,73 @@
+<div class="modal fade" data-keyboard="true" id="clone-hook" role="dialog" tabindex="-1">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header">
+                <button type="button" ng-click="destroyModal()" class="close">&times;</button>
+                <h3 class="text-center autoselect" style="word-wrap: break-word" ng-show="content.bssid">{{ content.ssid }}</h3>
+                <h4 class="text-center autoselect" style="word-wrap: break-word" ng-show="content.bssid">{{ content.bssid }}</h4>
+                <h4 class="text-center text-info" style="word-wrap: break-word" ng-show="!content.bssid">Unknown BSSID</h4>
+                <p class="text-center text-muted autoselect" ng-show="ouiPresent()">{{ oui }}</p>
+            </div>
+            <div class="modal-body">
+                <div ng-if="hook == 'encryption' && content.encryption.includes('Enterprise')">
+                    <div id="clone-actions" ng-if="hook == 'encryption'">
+                        <h4>Clone Enterprise Access Point</h4>
+                        <button type="button" class="btn btn-default" ng-click="cloneEnterpriseAP()" ng-disabled="working">
+                            <span ng-hide="working">Clone</span>
+                            <img src="img/throbber.gif" ng-show="working"/>
+                        </button><br/>
+                        <span class="text-muted small">Note: Cloning an Access Point may restart the wireless radios.</span>
+                    </div>
+                    <hr>
+                </div>
+
+                <div ng-show="hook == 'encryption' && content.encryption.includes('WPA') && !content.encryption.includes('Enterprise')">
+                    <div id="capture-handshake">
+                        <h4>Capture Wireless Handshake</h4>
+                        <div ng-hide="fullHandshakeFound || partialHandshakeFound || handshakeForOtherBSSID">
+                            <img ng-show="handshakeWorking" style="margin-right: 5px;" src="img/throbber.gif"/>
+                            <button type="button" class="btn btn-default" ng-click="toggleHandshakeCapture()" ng-disabled="handshakeStarting || error">
+                                <span ng-hide="handshakeWorking">Start Capture</span>
+                                <span ng-show="handshakeWorking">Stop Capture</span>
+                                <img src="img/throbber.gif" class="button-throbber" ng-show="handshakeStarting">
+                            </button>
+                            <button type="button" class="btn btn-default" ng-show="handshakeWorking" ng-click="deauthAP()" ng-disabled="deauthing">
+                                <span>Deauth</span>
+                                <img src="img/throbber.gif" class="button-throbber" ng-show="deauthing">
+                            </button>
+                        </div>
+                        <div ng-show="fullHandshakeFound || partialHandshakeFound">
+                            <p ng-show="fullHandshakeFound">Full handshake found for {{ content.bssid }}.</p>
+                            <p ng-show="partialHandshakeFound">Partial handshake found for {{ content.bssid }}.</p>
+                            <div class="btn-group">
+                                <button type="button" class="btn btn-default" ng-click="downloadHandshake('pcap');">Download PCAP</button>
+                                <button type="button" class="btn btn-default" ng-click="deleteHandshake();">Delete</button>
+                            </div>
+                        </div>
+                        <div ng-show="handshakeForOtherBSSID && !fullHandshakeFound && !partialHandshakeFound">
+                            <span>A handshake capture is already running for another BSSID ({{ currentBSSID }}).</span><br/>
+                            <button type="button" class="btn btn-default" ng-click="stopHandshakeCapture();">Stop Other Capture</button><br/>
+                        </div>
+                    </div>
+                    <hr>
+                </div>
+
+                <div class="modal-footer" ng-show="deletedHandshake">
+                    <div class="alert alert-success text-center">Successfully deleted handshake.</div>
+                </div>
+                <div class="modal-footer" ng-show="error == 'Please start PineAP'">
+                    <div class="alert alert-danger text-center">{{ error }}</div>
+                    <button ng-hide="pineAPStarting" type="button" class="btn btn-default center-block" ng-click="startPineAPD()">Start PineAP</button>
+                    <img class="center-block" ng-show="pineAPStarting" src="img/throbber.gif">
+                </div>
+            </div>
+            <div class="modal-footer" ng-show="success">
+                <div class="alert alert-success text-center">Action completed successfully.</div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script type="text/javascript">
+    $('#clone-hook').modal('show');
+</script>

+ 108 - 0
src/pineapple/html/hook-modal.html

@@ -0,0 +1,108 @@
+<div class="modal fade" data-keyboard="true" id="pineap-hook" role="dialog" tabindex="-1">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header">
+                <button type="button" ng-click="destroyModal()" class="close">&times;</button>
+                <h3 class="text-center autoselect" style="word-wrap: break-word" ng-show="content">{{ content }}</h3>
+                <h3 class="text-center text-info" style="word-wrap: break-word" ng-show="!content">Hidden SSID</h3>
+                <p class="text-center text-muted autoselect" ng-show="ouiPresent()" ng-if="hook == 'mac'">{{ oui }}</p>
+                <p class="text-center text-muted" ng-if="hook == 'mac'" ng-show="locallyAssignedMac()">
+                    This MAC was likely locally assigned and was not assigned by the hardware vendor.
+                    This could be the result of MAC randomization, Spoofing, or a vendor that has not registered with the IEEE Registration Authority.
+                </p>
+                <p class="text-center text-muted" ng-if="hook == 'mac'" ng-show="!locallyAssignedMac()">
+                    This MAC was likely globally assigned by the hardware vendor.
+                    It has probably not been randomized for privacy.
+                </p>
+                <img class="center-block" ng-show="ouiLoading && ouiPresent()" src="img/throbber.gif">
+            </div>
+            <div class="modal-body">
+                <div id="ssid-actions" ng-if="hook == 'ssid' && content != ''">
+                    <h4>PineAP Pool</h4>
+                    <button type="button" class="btn btn-default" ng-click="addSSIDToPool()">Add SSID</button>
+                    <button type="button" class="btn btn-default" ng-click="removeSSIDFromPool()">Remove SSID</button>
+                    <hr>
+                    <h4>PineAP Filter</h4>
+                    <button type="button" class="btn btn-default" ng-click="addSSIDToFilter()">Add SSID</button>
+                    <button type="button" class="btn btn-default" ng-click="removeSSIDFromFilter()">Remove SSID</button>
+                    <button ng-if="deauth.clients" type="button" class="btn btn-default" ng-click="addClientsToFilter()">Add all Clients</button>
+                    <hr ng-if="deauth && ((hook === 'ssid' && deauth.clients) || hook === 'mac')">
+                </div>
+                <div id="mac-actions" ng-if="hook == 'mac'">
+                    <h4>PineAP Filter</h4>
+                    <button type="button" class="btn btn-default" ng-click="addMACToFilter()">Add MAC</button>
+                    <button type="button" class="btn btn-default" ng-click="removeMacFromFilter()">Remove MAC</button>
+                    <hr>
+                    <h4>PineAP Tracking</h4>
+                    <button type="button" class="btn btn-default" ng-click="addMacToTracking()">Add MAC</button>
+                    <button type="button" class="btn btn-default" ng-click="removeMacFromTracking()">Remove MAC</button>
+                    <hr ng-if="deauth && ((hook === 'ssid' && deauth.clients) || hook === 'mac')">
+                </div>
+                <h4 ng-if="deauth && ((hook === 'ssid' && deauth.clients) || hook === 'mac')">Deauth Clients</h4>
+                <div class="form-group" ng-if="deauth && ((hook === 'ssid' && deauth.clients) || hook === 'mac')" ng-hide="error">
+                    <label for="deauthMultiply">Deauth Multiplier</label>
+                    <select class="form-control" id="deauthMultiply" ng-init="deauthMultiple = 1" ng-model="deauthMultiple" ng-options="multiplier for multiplier in [1,2,3,4,5,6,7,8,9,10]">
+                    </select>
+                    <br>
+                    <button type="button" class="btn btn-default" ng-if="hook === 'mac'" ng-click="deauthClient()" ng-disabled="deauthActive">
+                        Deauth <img src="../img/throbber.gif" class="button-throbber" ng-show="deauthActive"/>
+                    </button>
+                    <button type="button" class="btn btn-default" ng-if="hook === 'ssid'" ng-click="deauthAP()" ng-disabled="deauthActive">
+                        Deauth <img src="../img/throbber.gif" class="button-throbber" ng-show="deauthActive"/>
+                    </button>
+                </div>
+                <div ng-if="show_probes == true">
+                    <hr>
+                    <h4>PineAP Logged Probes</h4>
+                    <button type="button" class="btn btn-default" ng-click="loadProbes()">Load</button>
+                    <button type="button" class="btn btn-default" ng-click="addProbes()" ng-show="probes">Add all probes to PineAP Pool</button>
+                    <br>
+                    <br>
+                    <div class="alert well-sm alert-success" ng-show="probesAdded">All probes added to the PineAP Pool</div>
+                    <div class="alert alert-danger text-center" ng-show="probeError">{{ probeError }}</div>
+                    <textarea class="form-control" rows="10" ng-model="probes" ng-show="probes" readonly></textarea>
+                </div>
+                <div ng-if="hook == 'mac'">
+                    <hr>
+                    <h4>OUI</h4>
+                    <span class="autoselect" ng-show="ouiPresent()">{{ oui }}</span>
+                    <img class="center-block" ng-show="ouiLoading && ouiPresent()" src="img/throbber.gif">
+                    <div ng-hide="ouiPresent()">
+                        <button type="button" class="btn btn-default" ng-click="loadOUIFile()" ng-disabled="gettingOUI"><span ng-hide="gettingOUI">Download OUI File</span><img ng-show="gettingOUI" class="module-icon" src="img/throbber.gif"></button>
+                        <br/>
+                        <span class="small text-muted">Note: The OUI Database is downloaded from WiFiPineapple.com</span>
+                    </div>
+                </div>
+                <div>
+                    <hr>
+                    <h4>Notes</h4>
+                    <input class="form-control" type="text" name="name" ng-model="noteData.name" placeholder="Nickname">
+                    <textarea class="form-control" id="notes" rows="6" placeholder="Add notes..." ng-model="noteData.note">
+                    </textarea>
+                    <button class="btn btn-lg btn-default btn-block" type="button" ng-click="setNoteData()">
+                        Save Notes
+                    </button>
+                    <div class="alert well-sm alert-success" ng-show="noteSaved">Note saved</div>
+                </div>
+            </div>
+            <div class="modal-footer" ng-show="success">
+                <div class="alert alert-success text-center">Action completed successfully.</div>
+            </div>
+            <div class="modal-footer" ng-show="error == 'Please start PineAP'">
+                <div class="alert alert-danger text-center">{{ error }}</div>
+                <button ng-hide="pineAPStarting" type="button" class="btn btn-default center-block" ng-click="startPineAP()">Start PineAP</button>
+                <img class="center-block" ng-show="pineAPStarting" src="img/throbber.gif">
+            </div>
+            <div class="modal-footer" ng-show="error == 'This AP has no clients'">
+                <div class="alert alert-danger text-center">{{ error }}</div>
+            </div>
+            <div class="modal-footer" ng-show="error == 'An internet connection is required to download the OUI file'">
+                <div class="alert alert-danger text-center">{{ error }}</div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script type="text/javascript">
+$('#pineap-hook').modal('show');
+</script>

+ 60 - 0
src/pineapple/html/install-modal.html

@@ -0,0 +1,60 @@
+<div class="modal fade" data-keyboard="true" id="install-hook" role="dialog" tabindex="-1">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header">
+                <button type="button" onclick="destroyModal()" class="close">&times;</button>
+                <h3 class="text-center autoselect" style="word-wrap: break-word" ng-hide="content.updating">Install Module {{content.module['title']}}</h3>
+                <h3 class="text-center autoselect" style="word-wrap: break-word" ng-show="content.updating">Update Module {{content.module['title']}}</h3>
+            </div>
+            <div class="modal-body">
+                <div ng-show="content.module['type'] !== 'Sys'">
+                    <h4><b>Third Party Module</b></h4>
+                    <span>This module is developed by <b>{{content.module['author']}}</b>, a third-party developer.</span>
+                </div>
+
+                <div ng-show="content.module['type'] == 'Sys'">
+                    <h4><b>First Party Module</b></h4>
+                    <span>This module is developed by <b>Hak5</b>, a first-party developer.</span>
+                </div>
+
+                <span>Generally, module support can be found at <a href=“https://forums.hak5.org/forum/90-nano-tetra-modules/” target=“_blank”>forums.hak5.org.</a></span>
+
+
+                <span class="text-info" ng-show="(!selectedModule.sd && selectedModule.internal && device == 'nano')">
+                    Using an SD card instead of internal storage is strongly recommended.
+                </span>
+
+                <span ng-show="(!selectedModule.sd && !selectedModule.internal)">
+                    You do not have enough free space to install this module. Please insert an SD card and ensure that it is formatted correctly.
+                </span>
+
+                <br/>
+                <br/>
+
+                <div>
+                    <button class="btn btn-default" ng-disabled="downloading || installing" onclick="destroyModal()">Cancel</button>
+                    <button class="btn btn-primary pull-right" ng-disabled="downloading || installing" style="margin-left: 5px;" ng-show="selectedModule.sd && !content.updating" ng-click="downloadModule('sd');">Install to SD Card</button>
+                    <button class="btn btn-primary pull-right" ng-disabled="downloading || installing" ng-show="selectedModule.internal && !content.updating" ng-click="downloadModule('internal');">Install Internally</button>
+                    <button class="btn btn-primary pull-right" ng-disabled="downloading || installing" ng-show="content.updating" ng-click="downloadModule('internal');">Install Update</button>
+                </div>
+
+                <div class="panel-body text-center" ng-if='downloading === true'>
+                    <img src="img/throbber.gif"><br>
+                    Downloading Module, please wait.
+                </div>
+                <div class="panel-body text-center" ng-if='installing === true'>
+                    <img src="img/throbber.gif"><br>
+                    Installing Module, please wait.
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script type="text/javascript">
+    $('#install-hook').modal('show');
+
+    function destroyModal() {
+        $('#install-hook').modal('hide');
+    }
+</script>

二进制
src/pineapple/img/browser_chrome.png


二进制
src/pineapple/img/browser_ff.png


二进制
src/pineapple/img/browser_ie.png


二进制
src/pineapple/img/browser_opera.png


二进制
src/pineapple/img/browser_safari.png


二进制
src/pineapple/img/favicon.ico


二进制
src/pineapple/img/logo.png


二进制
src/pineapple/img/logout.png


二进制
src/pineapple/img/notify.png


二进制
src/pineapple/img/throbber.gif


+ 118 - 0
src/pineapple/index.html

@@ -0,0 +1,118 @@
+<!DOCTYPE html>
+<html lang="en" ng-app="pineapple">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1 maximum-scale=1, user-scalable=no">
+    <title>WiFi Pineapple</title>
+
+    <link rel="stylesheet" type="text/css" href="css/bootstrap.min.css">
+    <link rel="stylesheet" type="text/css" href="css/main.css">
+
+    <link rel="shortcut icon" href="img/favicon.ico" type="image/x-icon">
+    <link rel="icon" href="img/favicon.ico" type="image/x-icon">
+
+    <script src="js/vendor/jquery.min.js"></script>
+    <script src="js/vendor/bootstrap.min.js"></script>
+    <script src="js/vendor/angular.min.js"></script>
+    <script src="js/vendor/angular-route.min.js"></script>
+    <script src="js/vendor/angular-cookies.min.js"></script>
+    <script src="js/pineapple.js"></script>
+    <script src="js/services.js"></script>
+    <script src="js/filters.js"></script>
+    <script src="js/controllers.js"></script>
+    <script src="js/directives.js"></script>
+    <script src="js/helpers.js"></script>
+</head>
+
+<nav class="navbar navbar-default navbar-static-top">
+    <div class="navbar-header">
+        <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
+            <span class="sr-only">Toggle navigation</span>
+            <span class="icon-bar"></span>
+            <span class="icon-bar"></span>
+            <span class="icon-bar"></span>
+        </button>
+        <a class="navbar-brand" href="#!/module/Dashboard">
+            <span class="brand-logo"></span>
+            <span class="brand-text"></span>
+        </a>
+    </div>
+    <ul class="nav navbar-nav navbar-right navbar-top-links">
+        <li class="dropdown" ng-controller="NotificationController" ng-show="notifications.length">
+            <a class="dropdown-toggle" data-toggle="dropdown" href="">
+                <img width="18px" src="img/notify.png">
+                <span class="caret"></span>
+            </a>
+            <ul class="dropdown-menu dropdown-menu-top">
+                <li ng-repeat="notification in notifications"><a>{{ notification.message }}</a></li>
+                <li role="separator" class="divider"></li>
+                <li ng-click="clearNotifications()"><a><span class="text-center"><i>- Clear -</i></span></a></li>
+            </ul>
+        </li>
+        <li class="dropdown logout" ng-controller="AuthenticationController">
+            <a class="dropdown-toggle" data-toggle="dropdown" href="">
+                <img width="18px" src="img/logout.png">
+                <span class="caret"></span>
+            </a>
+            <ul class="dropdown-menu dropdown-menu-top dropdown-menu-logout">
+                <li ng-click="logout()"><a>Log Off</a></li>
+                <li ng-click="rebootPineapple()"><a>Reboot</a></li>
+                <li ng-click="haltPineapple()"><a>Shut Down</a></li>
+            </ul>
+        </li>
+    </ul>
+
+    <div class="navbar-default sidebar" role="navigation" ng-controller="NavigationController">
+        <div class="sidebar-nav navbar-collapse collapse">
+            <ul class="nav sidebar-nav">
+                <li ng-repeat-start="systemModule in systemModules" ng-class="getClass(systemModule.name)" module="{{ systemModule.name }}"><a href="#!/modules/{{ systemModule.name }}"><img class="module-icon" ng-src="{{ systemModule.icon }}" ng-if="systemModule.icon != null"> {{ systemModule.title }}</a></li>
+                <li ng-if="$index == 3" ng-repeat-end>
+                    <a ng-class="getModuleClass()" onclick="$('.module-nav').collapse('toggle')" href=''><img class="module-icon" src="/modules/ModuleManager/module_icon.svg"> Modules <span class="caret"></span></a>
+                    <ul class="nav module-nav collapse">
+                        <li ng-class="getClass('ModuleManager')" module="ModuleManager"><a href="#!/modules/ModuleManager">Manage Modules</a></li>
+                        <li ng-class="getClass(userModule.name)" ng-repeat="userModule in userModules" module="{{ userModule.name }}"><a href="#!/modules/{{ userModule.name }}"><img class="module-icon" ng-src="{{ userModule.icon }}" ng-if="userModule.icon != null"/> {{ userModule.title }}</a></li>
+                    </ul>
+                </li>
+            </ul>
+        </div>
+    </div>
+</nav>
+
+<div class="module-content" ng-view>
+
+</div>
+
+<div id="loginModal" class="modal fade" role="dialog"  data-keyboard="false" ng-controller="AuthenticationController">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h4 class="modal-title">WiFi Pineapple Login</h4>
+            </div>
+            <div class="modal-body">
+                <div class="row">
+                    <img class="img-responsive login-logo" src="img/logo.png">
+                </div>
+                <form role="form" ng-submit="login()">
+                    <fieldset>
+                        <div class="form-group">
+                            <input class="form-control" value="root" type="text" ng-model="username" tabindex="2" autocomplete="false">
+                        </div>
+                        <div class="form-group">
+                            <input class="form-control" placeholder="Password" type="password" ng-model="password" autofocus="autofocus" tabindex="1" autocomplete="current-password">
+                        </div>
+                        <div class="form-group">
+                            <div class="alert alert-danger" ng-show="message">
+                                {{ message }}
+                            </div>
+                        </div>
+                        <input class="btn btn-lg btn-success btn-block" type="submit" id="submit" value="Login" />
+                    </fieldset>
+                </form>
+            </div>
+        </div>
+
+    </div>
+</div>
+
+</body>
+</html>

+ 114 - 0
src/pineapple/js/controllers.js

@@ -0,0 +1,114 @@
+(function(){
+    angular.module('pineapple')
+    .controller('NavigationController', ['$scope', '$api', '$routeParams', function($scope, $api, $routeParams) {
+        $scope.systemModules = [];
+        $scope.userModules = [];
+        $scope.selectedIndex = -1;
+
+        $scope.getClass = function(moduleName) {
+            if ($routeParams.moduleName === $(".sidebar-nav li[module="+ moduleName +"]").attr("module")) {
+                return 'active';
+            } else {
+                return '';
+            }
+        };
+
+        $scope.getModuleClass = function() {
+            if ($(".module-nav li.active").length) {
+                return 'active';
+            } else {
+                return '';
+            }
+        };
+
+        $scope.getModuleList = (function () {
+            $api.request({
+                system: 'modules',
+                action: 'getModuleList'
+            }, function(data) {
+                if (data.error === undefined){
+                    $scope.systemModules = data.modules.systemModules;
+                    $scope.userModules = data.modules.userModules;
+                }
+            });
+        });
+
+        $api.registerNavbar($scope.getModuleList);
+        $scope.getModuleList();
+    }])
+
+
+    .controller('NotificationController', ['$scope', '$api', '$interval', function($scope, $api, $interval){
+        $scope.notifications = [];
+
+        $api.getNotifications(function(data){
+            $scope.notifications = data;
+        });
+
+        $scope.clearNotifications = function(){
+            $scope.notifications = [];
+            $api.clearNotifications();
+        };
+
+        $scope.notificationInterval = $interval(function() {
+            $api.getNotifications(function(data){
+                $scope.notifications = data;
+            });
+        }, 6000);
+
+        $scope.$on('$destroy', function() {
+            $interval.cancel($scope.notificationInterval);
+        });
+    }])
+
+
+    .controller('AuthenticationController', ['$scope', '$api', function($scope, $api){
+        $scope.username = "root";
+        $scope.password = "";
+        $scope.message = "";
+
+
+        $scope.login = function(){
+            $api.login($scope.username, $scope.password, function(data){
+                if (data.logged_in !== undefined && data.logged_in === false) {
+                    $scope.message = "Invalid username or password.";
+                } else {
+                    window.location.reload();
+                }
+            });
+        };
+
+        $scope.logout = function(){
+            $api.logout(function(){
+                window.location.reload();
+            });
+        };
+
+        $scope.haltPineapple = (function() {
+            if (confirm("Are you sure you want to shutdown your WiFi Pineapple?")) {
+                $api.request({
+                    module: "Configuration",
+                    action: "haltPineapple"
+                }, function(response) {
+                    if (response.success !== undefined) {
+                        alert("Your WiFi Pineapple is now shutting down. Once the LED has turned off, it is safe to unplug.");
+                    }
+                });
+            }
+        });
+
+        $scope.rebootPineapple = (function() {
+            if (confirm("Are you sure you want to reboot your WiFi Pineapple?")) {
+                $api.request({
+                    module: "Configuration",
+                    action: "rebootPineapple"
+                }, function(response) {
+                    if (response.success !== undefined) {
+                        alert("Your WiFi Pineapple is now rebooting. You may need to reconnect once it is done.");
+                    }
+                });
+            }
+        });
+
+    }]);
+})();

+ 927 - 0
src/pineapple/js/directives.js

@@ -0,0 +1,927 @@
+(function(){
+    angular.module('pineapple')
+    .directive('hookModal', function(){
+        return {
+            restrict: 'E',
+            templateUrl: '/html/hook-modal.html',
+            scope: {
+                hook: '=hook',
+                content: '=content',
+                deauth: '=deauth',
+                client: '=client',
+                show_probes: '=probes'
+            },
+            controller: ['$scope', '$api', '$timeout', '$http', '$interval', function($scope, $api, $timeout, $http, $interval){
+                $scope.error = '';
+                $scope.success = false;
+                $scope.pineAPStarting = false;
+                $scope.probes = "";
+                $scope.oui = null;
+                $scope.ouiLoading = false;
+                $scope.gettingOUI = false;
+                $scope.noteData = {
+                    name: "",
+                    note: ""
+                };
+                $scope.noteSaved = false;
+
+                $scope.handleResponse = function(response){
+                    if (response.error === undefined) {
+                        $scope.success = true;
+                        $timeout(function() {
+                            $scope.success = false;
+                        }, 2000);
+                    } else {
+                        $scope.error = response.error;
+                    }
+                };
+
+                $scope.locallyAssignedMac = function() {
+                  return locallyAssigned($scope.content);
+                };
+
+                $scope.destroyModal = function(){
+                    $('#pineap-hook').modal('hide').detach();
+                };
+
+                $scope.startPineAP = function(){
+                    $scope.pineAPStarting = true;
+                    $api.request({
+                        module: 'PineAP',
+                        action: 'enable'
+                    }, function(response){
+                        $scope.error = response.error;
+                        $scope.pineAPStarting = false;
+                    });
+                };
+                $scope.addSSIDToPool = function(){
+                    $api.request({
+                        module: 'PineAP',
+                        action: 'addSSID',
+                        ssid: $scope.content
+                    }, $scope.handleResponse);
+                };
+                $scope.removeSSIDFromPool = function(){
+                    $api.request({
+                        module: 'PineAP',
+                        action: 'removeSSID',
+                        ssid: $scope.content
+                    }, $scope.handleResponse);
+                };
+                $scope.addSSIDToFilter = function(){
+                    $api.request({
+                        module: 'Filters',
+                        action: 'addSSID',
+                        ssid: $scope.content
+                    }, $scope.handleResponse);
+                };
+                $scope.removeSSIDFromFilter = function(){
+                    $api.request({
+                        module: 'Filters',
+                        action: 'removeSSID',
+                        ssid: $scope.content
+                    }, $scope.handleResponse);
+                };
+                $scope.deauthAP = function(){
+                    var deauthMultiplier = $('#deauthMultiply').val().replace(/[^0-9]/g, "");
+                    $api.request({
+                        module: 'PineAP',
+                        action: 'deauth',
+                        sta: $scope.deauth.bssid,
+                        clients: $scope.deauth.clients,
+                        channel: $scope.deauth.channel,
+                        multiplier: deauthMultiplier
+                    }, function(response) {
+                        $scope.handleResponse(response);
+                        $scope.deauthActive = true;
+                        $timeout(function () {
+                            $scope.deauthActive = false;
+                        }, 5000);
+                    });
+                };
+                $scope.addMACToFilter = function(){
+                    $api.request({
+                        module: 'Filters',
+                        action: 'addClient',
+                        mac: $scope.content
+                    }, $scope.handleResponse);
+                };
+                $scope.removeMacFromFilter = function(){
+                    $api.request({
+                        module: 'Filters',
+                        action: 'removeClient',
+                        mac: $scope.content
+                    }, $scope.handleResponse);
+                };
+                $scope.addClientsToFilter = function() {
+                    $api.request({
+                        module: 'Filters',
+                        action: 'addClients',
+                        clients: $scope.deauth.clients
+                    }, $scope.handleResponse);
+                };
+                $scope.addMacToTracking = function(){
+                    $api.request({
+                        module: 'Tracking',
+                        action: 'addMac',
+                        mac: $scope.content
+                    }, $scope.handleResponse);
+                };
+                $scope.removeMacFromTracking = function(){
+                    $api.request({
+                        module: 'Tracking',
+                        action: 'removeMac',
+                        mac: $scope.content
+                    }, $scope.handleResponse);
+                };
+                $scope.deauthClient = function(){
+                    var deauthMultiplier = $('#deauthMultiply').val().replace(/[^0-9]/g, "");
+                    $api.request({
+                        module: 'PineAP',
+                        action: 'deauth',
+                        sta: $scope.deauth.bssid,
+                        clients: [$scope.content],
+                        channel: $scope.deauth.channel,
+                        multiplier: deauthMultiplier
+                    }, function(response) {
+                        $scope.handleResponse(response);
+                        $scope.deauthActive = true;
+                        $timeout(function() {
+                            $scope.deauthActive = false;
+                        }, 5000);
+                    });
+                };
+                $scope.loadProbes = function(){
+                    $api.request({
+                        module: 'PineAP',
+                        action: 'loadProbes',
+                        mac: $scope.content
+                    }, function(response) {
+                        if (response.success) {
+                            $scope.probes = response.probes;
+                            if ($scope.probes.length === 0) {
+                                $scope.probeError = "There are no logged probes for this MAC Address.";
+                            }
+                        } else {
+                            if (response.reason === "not running") {
+                                $scope.probeError = "PineAP must be enabled to load probes.";
+                            }
+                        }
+                    });
+                };
+                $scope.addProbes = function(){
+                    $api.request({
+                        module: 'PineAP',
+                        action: 'addSSIDs',
+                        ssids: $scope.probes.split("\n")
+                    }, function(response) {
+                        $scope.probesAdded = response.success;
+                    });
+                };
+
+                $scope.loadOUIFile = (function() {
+                    if (typeof(Storage) === "undefined") {
+                        return false;
+                    }
+                    var ouiText = localStorage.getItem("ouiText");
+                    if (ouiText === null) {
+                            $scope.gettingOUI = true;
+                            $http.get('https://www.wifipineapple.com/oui.txt').then(
+                            function(response) {
+                                localStorage.setItem("ouiText", response.data);
+                                $scope.populateDB();
+                            },
+                            function() {
+                                $api.request({
+                                    module: "Networking",
+                                    action: "getOUI"
+                                }, function(response) {
+                                    if (response.error === undefined) {
+                                        localStorage.setItem("ouiText", response.ouiText);
+                                        $scope.populateDB();
+                                    } else {
+                                        return false;
+                                    }
+                                });
+                            });
+                    }
+                    return true;
+                });
+
+                $scope.lookupOUI = function() {
+                    $scope.ouiLoading = true;
+                    if (!$scope.ouiPresent()) {
+                        return;
+                    }
+
+                    var request = window.indexedDB.open("pineapple", 1);
+                    request.onsuccess = function() {
+                        var db = request.result;
+                        var prefix = $scope.content.substring(0,8).replace(/:/g,'');
+                        var transaction = db.transaction(["oui"], 'readwrite');
+                        transaction.onerror = function() {
+                            $scope.oui = "Error retrieving OUI. Please clear your browsers cache."
+                        };
+                        var objectStore = transaction.objectStore("oui");
+                        var lookupReq = objectStore.get(prefix);
+                        lookupReq.onerror = function() {
+                            window.indexedDB.deleteDatabase("pineapple");
+                            $scope.oui = "Error retrieving OUI";
+                        };
+                        lookupReq.onsuccess = function() {
+                            if (lookupReq.result) {
+                                $scope.oui = lookupReq.result.name;
+                            } else {
+                                $scope.oui = "Unknown MAC prefix";
+                            }
+                        };
+                        $scope.ouiLoading = false;
+                    }
+                };
+
+                $scope.ouiPresent = function() {
+                    return localStorage.getItem("ouiText") !== null;
+                };
+
+                $scope.populateDB = function() {
+                    $scope.ouiLoading = true;
+                    var request = window.indexedDB.open("pineapple", 1);
+
+                    request.onsuccess = function() {
+                        $scope.lookupOUI();
+                    };
+
+                    request.onerror = function(event) {};
+
+                    request.onupgradeneeded = function(event) {
+                        var db = event.target.result;
+                        var objectStore = db.createObjectStore("oui", { keyPath: "macPrefix"});
+                        var text = localStorage.getItem("ouiText");
+                        var pos = 0;
+                        do {
+                            var line = text.substring(pos, text.indexOf("\n", pos + 1)).replace('\n', '');
+                            var arr = [line.substring(0, 6), line.substring(6)];
+                            objectStore.add({
+                                macPrefix: arr[0],
+                                name: arr[1]
+                            });
+                            pos += line.length + 1;
+                        } while (text.indexOf("\n", pos + 1) !== -1);
+                    };
+                };
+                $scope.deleteOUI = function() {
+                    localStorage.removeItem('ouiText');
+                    window.indexedDB.deleteDatabase('pineapple').onsuccess = function() {
+                        $scope.success = true;
+                        $scope.ouiLoading = false;
+                        $scope.gettingOUI = false;
+                        $timeout(function() {
+                            $scope.success = false;
+                        }, 2000);
+                    };
+                };
+                $scope.getNoteData = function() {
+                    $api.request({
+                        module: "Notes",
+                        action: "getNote",
+                        key: $scope.content
+                    }, function(response) {
+                        if (response.note !== null && response.note[0] !== undefined) {
+                            $scope.noteData = response.note[0];
+                        }
+                    });
+                };
+                $scope.setNoteData = function() {
+                    $api.request({
+                        module: "Notes",
+                        action: "setNote",
+                        type: $scope.hook === "mac" ?  0 : 1,
+                        key: $scope.content,
+                        name: $scope.noteData.name,
+                        note: $scope.noteData.note
+                    }, function() {
+                        $scope.getNoteData();
+                        $scope.noteSaved = true;
+                    });
+                };
+                $scope.lookupOUI();
+                $scope.getNoteData();
+            }]
+        };
+    })
+    .directive('hookButton', function(){
+        return {
+            restrict: 'E',
+            template: '<button ng-disabled="disable" ng-click="showModal($event)" class="btn btn-xs btn-default" type="button"><span class="caret"></span></button>',
+            scope: {
+                hook: '@hook',
+                content: '=content',
+                deauth: '=deauth',
+                client: '=client',
+                show_probes: '=probes',
+                disable: '=disable'
+            },
+            controller: ['$scope', '$compile', function($scope, $compile){
+                $scope.makeModalWithContent = function(){
+
+                    var html = '<hook-modal hook="hook" content="content"';
+                    if ($scope.deauth !== undefined) {
+                        html += ' deauth="deauth"';
+                    }
+                    if ($scope.show_probes !== undefined) {
+                        html += ' probes="true"';
+                    }
+                    html += '></hook-modal>';
+                    var el = $compile(html)($scope);
+                    $('body').append(el);
+                    $('#pineap-hook').modal({
+                        show: true,
+                        keyboard: false,
+                        backdrop: 'static'
+                    });
+                };
+                $scope.showModal = function(){
+                    $('#pineap-hook').remove();
+                    $scope.makeModalWithContent();
+                };
+            }]
+            };
+        })
+        .directive('cloneModal', function(){
+            return {
+                restrict: 'E',
+                templateUrl: '/html/clone-modal.html',
+                scope: {
+                    hook: '=hook',
+                    content: '=content',
+                    disable: '=disable'
+                },
+                controller: ['$scope', '$api', '$rootScope', '$timeout', '$http', '$interval', function($scope, $api, $rootScope, $timeout, $http, $interval){
+                    $scope.error = '';
+                    $scope.currentBSSID = '';
+                    $scope.working = false;
+                    $scope.success = false;
+                    $scope.oui = null;
+                    $scope.ouiLoading = false;
+                    $scope.gettingOUI = false;
+                    $scope.fullHandshakeFound = false;
+                    $scope.partialHandshakeFound = false;
+                    $scope.handshakeWorking = false;
+                    $scope.handshakeStarting = false;
+                    $scope.handshakeForOtherBSSID = false;
+
+                    $scope.handleResponse = function(response){
+                        if (response.error === undefined) {
+                            $scope.success = true;
+                            $timeout(function() {
+                                $scope.success = false;
+                            }, 2000);
+                        } else {
+                            $scope.error = response.error;
+                        }
+                    };
+                    $scope.cloneEnterpriseAP = function(){
+                        $scope.working = true;
+
+                        x = '0x' + $scope.content.bssid.slice(-2);
+                        if (x === '0xFF')
+                            x = '0x00';
+                        newOctet = (parseInt(x, 16) + 0x1).toString(16);
+                        if (x.charAt(2) === '0')
+                            newOctet = ['0', newOctet.slice(0)].join('');
+                        newMac = $scope.content.bssid.slice(0, -2) + newOctet;
+
+                        let settings = {
+                            enabled: false,
+                            enableAssociations: false,
+                            ssid: $scope.content.ssid,
+                            mac: newMac,
+                            encryptionType: $scope.encryptionTranslate($scope.content.encryption)
+                        };
+                        $api.request({
+                            module: 'PineAP',
+                            action: 'setEnterpriseSettings',
+                            settings: settings
+                        }, function(response) {
+                            if (response.success === true) {
+                                $scope.success = true;
+                                $scope.working = false;
+                                $timeout(function(){
+                                    $scope.success = false;
+                                }, 2000);
+                            }
+                        });
+                    };
+
+                    $scope.encryptionTranslate = function(uiVal) {
+                        let lookup = {
+                            "WPA2 Enterprise (CCMP)": "wpa2+ccmp",
+                            "WPA2 Enterprise (TKIP)": "wpa2+tkip",
+                            "WPA2 Enterprise (TKIP CCMP)": "wpa2+ccmp+tkip",
+                            "WPA Enterprise (CCMP)": "wpa+ccmp",
+                            "WPA Enterprise (TKIP)": "wpa+tkip",
+                            "WPA Enterprise (CCMP TKIP)": "wpa+ccmp+tkip",
+                            "WPA Mixed Enterprise (CCMP)": "wpa-mixed+ccmp",
+                            "WPA Mixed Enterprise (TKIP)": "wpa-mixed+tkip",
+                            "WPA Mixed Enterprise (CCMP TKIP)": "wpa-mixed+ccmp+tkip"
+                        };
+                        return lookup[uiVal];
+                    };
+                    $scope.translatedEncryption = $scope.encryptionTranslate($scope.content.encryption);
+
+                    $scope.checkifHandshakeExists = function() {
+                        $api.request({
+                            module: 'PineAP',
+                            action: 'getHandshake',
+                            bssid: $scope.content.bssid
+                        }, function(response) {
+                            if (response.handshakeExists) {
+                                if (response.partial) {
+                                    $scope.partialHandshakeFound = true;
+                                } else if (response.partial === false) {
+                                    $scope.fullHandshakeFound = true;
+                                }
+
+                                if ($scope.handshakeWorking) {
+                                    $scope.handshakeWorking = false;
+                                }
+
+                                $scope.stopHandshakeCapture();
+                            }
+                        })
+                    };
+
+                    $scope.checkIfCaptureRunning = function() {
+                        $api.request({
+                            module: 'PineAP',
+                            action: 'checkCaptureStatus',
+                            bssid: $scope.content.bssid
+                        }, function(response) {
+                            if (response.running && response.currentBSSID) {
+                                // Running for current BSSID.
+                                $scope.handshakeWorking = true;
+                                $rootScope.captureRunning = true;
+                            } else if (response.running) {
+                                // Running for another BSSID.
+                                $scope.handshakeForOtherBSSID = true;
+                                $scope.currentBSSID = response.bssid;
+                                $rootScope.captureRunning = true;
+                            } else {
+                                // Not running at all.
+                                $scope.handshakeWorking = false;
+                                $rootScope.captureRunning = false;
+                            }
+                        })
+                    };
+
+                    $scope.stopHandshakeCapture = function() {
+                        $api.request({
+                            module: 'PineAP',
+                            action: 'stopHandshakeCapture'
+                        }, function(response) {
+                            if (response.success) {
+                                $scope.handshakeStarting = false;
+                                $scope.handshakeWorking = false;
+                                $scope.handshakeForOtherBSSID = false;
+                                $rootScope.captureRunning = false;
+                                $interval.cancel($scope.updateInterval);
+                            }
+                        })
+                    };
+
+                    $scope.startHandshakeCapture = function() {
+                        $scope.handshakeStarting = true;
+                        $api.request({
+                            module: 'PineAP',
+                            action: 'startHandshakeCapture',
+                            bssid: $scope.content.bssid,
+                            channel: $scope.content.channel
+                        }, function(response) {
+                            if (response.success) {
+                                $rootScope.captureRunning = true;
+                                $timeout(function(){
+                                    $scope.handshakeWorking = true;
+                                    $scope.handshakeStarting = false;
+                                }, 5000);
+                                $scope.updateInterval = $interval(function() {
+                                    $scope.checkifHandshakeExists();
+                                }, 5000)
+                            } else if (response.error) {
+                                $scope.handshakeWorking = false;
+                                $scope.handshakeStarting = false;
+                                $scope.error = response.error;
+                            }
+                        })
+                    };
+
+                    $scope.toggleHandshakeCapture = function() {
+                        if($scope.handshakeWorking) {
+                            $scope.stopHandshakeCapture();
+                        } else {
+                            $scope.startHandshakeCapture();
+                        }
+                    };
+
+                    $scope.downloadHandshake = function($type) {
+                        $api.request({
+                            module: 'PineAP',
+                            action: 'downloadHandshake',
+                            bssid: $scope.content.bssid,
+                            type: $type
+                        }, function(response) {
+                            if (response.error === undefined) {
+                                window.location = '/api?download=' + response.download;
+                            }
+                        });
+                    };
+
+                    $scope.deleteHandshake = function() {
+                        $api.request({
+                            module: 'PineAP',
+                            action: 'deleteHandshake',
+                            bssid: $scope.content.bssid
+                        }, function(response) {
+                            if (response.success) {
+                                $scope.deletedHandshake = true;
+                                $scope.fullHandshakeFound = false;
+                                $scope.partialHandshakeFound = false;
+                                $interval.cancel($scope.updateInterval);
+                                $timeout(function() {
+                                    $scope.deletedHandshake = false;
+                                }, 5000);
+                            }
+                        })
+                    };
+
+                    $scope.deauthAP = function() {
+                        $scope.deauthing = true;
+                        $api.request({
+                            module: 'PineAP',
+                            action: 'deauth',
+                            sta: $scope.content.bssid,
+                            clients: $scope.content.clients,
+                            multiplier: 2,
+                            channel: $scope.content.channel
+                        }, function(response) {
+                            }
+                        );
+
+                        $timeout(function(){
+                            $scope.deauthing = false;
+                        }, 5000);
+                    };
+
+                    $scope.startPineAPD = function(){
+                        $scope.error = '';
+                        $scope.pineAPStarting = true;
+                        $api.request({
+                            module: 'PineAP',
+                            action: 'enable'
+                        }, function(response){
+                            $scope.error = response.error;
+                            $scope.pineAPStarting = false;
+                        });
+                    };
+
+                    $scope.loadOUIFile = (function() {
+                        if (typeof(Storage) === "undefined") {
+                            return false;
+                        }
+                        var ouiText = localStorage.getItem("ouiText");
+                        if (ouiText === null) {
+                            $scope.gettingOUI = true;
+                            $http.get('https://www.wifipineapple.com/oui.txt').then(
+                                function(response) {
+                                    localStorage.setItem("ouiText", response.data);
+                                    $scope.populateDB();
+                                },
+                                function() {
+                                    $api.request({
+                                        module: "Networking",
+                                        action: "getOUI"
+                                    }, function(response) {
+                                        if (response.error === undefined) {
+                                            localStorage.setItem("ouiText", response.ouiText);
+                                            $scope.populateDB();
+                                        } else {
+                                            return false;
+                                        }
+                                    });
+                                });
+                        }
+                        return true;
+                    });
+
+                    $scope.lookupOUI = function() {
+                        $scope.ouiLoading = true;
+                        if (!$scope.ouiPresent()) {
+                            return;
+                        }
+
+                        var request = window.indexedDB.open("pineapple", 1);
+                        request.onsuccess = function() {
+                            var db = request.result;
+                            var prefix = $scope.content.bssid.substring(0,8).replace(/:/g,'');
+                            var transaction = db.transaction("oui");
+                            var objectStore = transaction.objectStore("oui");
+                            var lookupReq = objectStore.get(prefix);
+                            lookupReq.onerror = function() {
+                                window.indexedDB.deleteDatabase("pineapple");
+                                $scope.oui = "Error retrieving OUI";
+                            };
+                            lookupReq.onsuccess = function() {
+                                if (lookupReq.result) {
+                                    $scope.oui = lookupReq.result.name;
+                                } else {
+                                    $scope.oui = "Unknown MAC prefix";
+                                }
+                            };
+                            $scope.ouiLoading = false;
+                        }
+                    };
+
+                    $scope.ouiPresent = function() {
+                        return localStorage.getItem("ouiText") !== null;
+                    };
+
+                    $scope.populateDB = function() {
+                        $scope.ouiLoading = true;
+                        var request = window.indexedDB.open("pineapple", 1);
+
+                        request.onsuccess = function() {
+                            $scope.lookupOUI();
+                        };
+
+                        request.onerror = function(event) {
+                        };
+
+                        request.onupgradeneeded = function(event) {
+                            var db = event.target.result;
+                            var objectStore = db.createObjectStore("oui", { keyPath: "macPrefix"});
+                            var text = localStorage.getItem("ouiText");
+                            var pos = 0;
+                            do {
+                                var line = text.substring(pos, text.indexOf("\n", pos + 1)).replace('\n', '');
+                                var arr = [line.substring(0, 6), line.substring(6)];
+                                objectStore.add({
+                                    macPrefix: arr[0],
+                                    name: arr[1]
+                                });
+                                pos += line.length + 1;
+                            } while (text.indexOf("\n", pos + 1) !== -1);
+                        };
+                    };
+                    $scope.deleteOUI = function() {
+                        localStorage.removeItem('ouiText');
+                        window.indexedDB.deleteDatabase('pineapple').onsuccess = function() {
+                            $scope.success = true;
+                            $scope.ouiLoading = false;
+                            $scope.gettingOUI = false;
+                            $timeout(function() {
+                                $scope.success = false;
+                            }, 2000);
+                        };
+                    };
+
+                    $scope.lookupOUI();
+                    $scope.checkifHandshakeExists();
+                    $scope.checkIfCaptureRunning();
+                    $scope.destroyModal = function(){
+                        $('#clone-hook').modal('hide').detach();
+                    };
+                }]
+            };
+        })
+        .directive('cloneButton', function(){
+            return {
+                restrict: 'E',
+                template: '<button ng-disabled="disable" ng-click="showModal($event)" class="btn btn-xs btn-default" type="button"><span class="caret"></span></button>',
+                scope: {
+                    hook: '@hook',
+                    content: '=content',
+                    disable: '=disable'
+                },
+                controller: ['$scope', '$compile', function($scope, $compile){
+                    $scope.makeModalWithContent = function(){
+                        var html = '<clone-modal hook="hook" content="content"';
+                        html += '></clone-modal>';
+                        var el = $compile(html)($scope);
+                        $('body').append(el);
+                        $('#clone-hook').modal({
+                            show: true,
+                            keyboard: false,
+                            backdrop: 'static'
+                        });
+                    };
+                    $scope.showModal = function($event){
+                        $('#clone-hook').remove();
+                        $scope.makeModalWithContent();
+                    };
+                }]
+            };
+        })
+        .directive('installModal', function(){
+            return {
+                restrict: 'E',
+                templateUrl: '/html/install-modal.html',
+                scope: {
+                    hook: '@hook',
+                    content: '=content'
+                },
+                controller: ['$scope', '$api', '$timeout', '$http', '$interval', '$templateCache', '$rootScope', function($scope, $api, $timeout, $http, $interval, $templateCache, $rootScope){
+                    $scope.device = '';
+                    $rootScope.installedModules = [];
+                    $scope.selectedModule = null;
+
+                    $scope.destroyModal = function(){
+                        $('#install-hook').modal('hide');
+                    };
+
+                    $scope.getDevice = (function() {
+                        $api.request({
+                            module: "Configuration",
+                            action: "getDevice"
+                        }, function(response) {
+                            $scope.device = response.device;
+                        });
+                    });
+                    $scope.getDevice();
+
+                    $scope.getInstalledModules = (function() {
+                        $api.request({
+                            module: "ModuleManager",
+                            action: "getInstalledModules"
+                        }, function(response) {
+                            $rootScope.installedModules = response.installedModules;
+                            $scope.compareModuleLists();
+                        });
+                    });
+
+                    $scope.compareModuleLists = (function() {
+                        angular.forEach($rootScope.availableModules, function(module, moduleName){
+                            if ($rootScope.installedModules[moduleName] === undefined){
+                                module['installable'] = true;
+                            } else if ($rootScope.availableModules[moduleName].version <= $rootScope.installedModules[moduleName].version) {
+                                module['installed'] = true;
+                            }
+                        });
+                    });
+
+                    $scope.checkDestination = (function(moduleName, moduleSize, moduleType) {
+                        $(window).scrollTop(0);
+
+                        if (moduleType === 'Sys') {
+                            $scope.selectedModule = {module: moduleName, internal: true, sd: false};
+                            return;
+                        }
+
+                        if ($scope.device === 'tetra') {
+                            $scope.selectedModule = {module: moduleName, internal: true, sd: false};
+                            return;
+                        }
+
+                        $api.request({
+                            module: 'ModuleManager',
+                            action: 'checkDestination',
+                            name: moduleName,
+                            size: moduleSize
+                        }, function(response) {
+                            if (response.error === undefined) {
+                                $scope.selectedModule = response;
+                            }
+                        });
+                    });
+                    $scope.checkDestination($scope.content.name, $scope.content.module['size'], $scope.content.module['type']);
+
+                    $scope.downloadModule = (function(dest) {
+                        $api.request({
+                            module: 'ModuleManager',
+                            action: 'downloadModule',
+                            moduleName: $scope.selectedModule.module,
+                            destination: dest
+                        }, function(response) {
+                            if (response.error === undefined) {
+                                $scope.downloading = true;
+                                var ival = $interval(function() {
+                                    $api.request({
+                                        module: 'ModuleManager',
+                                        action: 'downloadStatus',
+                                        moduleName: $scope.selectedModule.module,
+                                        destination: dest,
+                                        checksum: $rootScope.availableModules[$scope.selectedModule.module]['checksum']
+                                    }, function(response) {
+                                        if (response.success === true) {
+                                            $interval.cancel(ival);
+                                            $scope.installModule(dest);
+                                        }
+                                    });
+                                }, 2000);
+                            }
+                        });
+                    });
+
+                    $scope.installModule = (function(dest) {
+                        if ($scope.installing) {
+                            return;
+                        }
+                        $scope.downloading = false;
+                        $scope.installing = true;
+
+                        $api.request({
+                            module: 'ModuleManager',
+                            action: 'installModule',
+                            moduleName: $scope.selectedModule.module,
+                            destination: dest
+                        }, function() {
+                            var ival = $interval(function() {
+                                $api.request({
+                                    module: 'ModuleManager',
+                                    action: 'installStatus'
+                                }, function(response) {
+                                    if (response.success === true) {
+                                        $interval.cancel(ival);
+                                        $templateCache.removeAll();
+                                        $scope.installedModule = true;
+                                        $scope.installing = false;
+                                        $scope.getInstalledModules();
+                                        $api.reloadNavbar();
+                                        if ($scope.selectedModule.module === 'ModuleManager') {
+                                            window.location.reload();
+                                        } else {
+                                            $scope.selectedModule = null;
+                                            $scope.destroyModal();
+                                        }
+                                        $timeout(function(){
+                                            $scope.installedModule = false;
+                                        }, 2000);
+                                    }
+                                });
+                            }, 500);
+                        });
+                    });
+                }]
+            };
+        })
+        .directive('installButton', function(){
+            return {
+                restrict: 'E',
+                template: '<button ng-disabled="disable" ng-click="showModal($event)" class="btn btn-default btn-xs btn-fixed-length" type="button">Install</button>',
+                scope: {
+                    hook: '@hook',
+                    content: '=content'
+                },
+                controller: ['$scope', '$compile', function($scope, $compile){
+                    $scope.makeModalWithContent = function(){
+                        var html = '<install-modal hook="hook" content="content"></install-modal>';
+                        var el = $compile(html)($scope);
+                        $('body').append(el);
+                        $('#install-hook').modal({
+                            show: true,
+                            keyboard: false,
+                            backdrop: 'static'
+                        });
+                    };
+
+                    $scope.showModal = function(){
+                        $('#install-hook').remove();
+                        $scope.makeModalWithContent();
+                    };
+
+                    $scope.destroyModal = function(){
+                        $('#install-hook').modal('hide');
+                    };
+                }]
+            };
+        })
+        .directive('updateButton', function(){
+            return {
+                restrict: 'E',
+                template: '<button ng-disabled="disable" ng-click="showModal($event)" class="btn btn-primary btn-xs btn-fixed-length" type="button">Update</button>',
+                scope: {
+                    hook: '@hook',
+                    content: '=content'
+                },
+                controller: ['$scope', '$compile', function($scope, $compile){
+                    $scope.makeModalWithContent = function(){
+                        var html = '<install-modal hook="hook" content="content"></install-modal>';
+                        var el = $compile(html)($scope);
+                        $('body').append(el);
+                        $('#install-hook').modal({
+                            show: true,
+                            keyboard: false,
+                            backdrop: 'static'
+                        });
+                    };
+
+                    $scope.showModal = function(){
+                        $('#install-hook').remove();
+                        $scope.makeModalWithContent();
+                    };
+
+                    $scope.destroyModal = function(){
+                        $('#install-hook').modal('hide');
+                    };
+                }]
+            };
+        })
+})();

+ 103 - 0
src/pineapple/js/filters.js

@@ -0,0 +1,103 @@
+(function () {
+    angular.module('pineapple')
+        .filter('timesince', function () {
+            return function (input) {
+                var then = new Date(input * 1000);
+                var now = new Date();
+                var hoursSince = Math.round(Math.abs(now - then) / 1000 / 60 / 60);
+                var minutesSince = Math.round(Math.abs(now - then) / 60 / 1000);
+                var secondsSince = Math.round(Math.abs(now - then) / 1000);
+                if (secondsSince >= 1 && secondsSince < 60) {
+                    return secondsSince + ' ' + (secondsSince === 1 ? 'second' : 'seconds') + ' ago';
+                } else if (minutesSince >= 1 && minutesSince < 60) {
+                    return minutesSince + ' ' + (minutesSince === 1 ? 'minute' : 'minutes') + ' ago';
+                } else if (hoursSince >= 1 && hoursSince < 24) {
+                    return hoursSince + ' ' + (hoursSince === 1 ? 'hour' : 'hours') + ' ago';
+                } else {
+                    var hours = then.getHours();
+                    var minutes = then.getMinutes();
+                    var month = then.getMonth();
+                    var day = then.getDay();
+                    var year = then.getYear();
+                    return 'at ' + (year + 1990) + '-' + month + '-' + day + ' ' + (hours % 12 === 0 ? '12' : (hours % 12).toString()) + ':' + minutes + (hours > 12 ? ' PM' : ' AM');
+                }
+            }
+        })
+
+        .filter('timesincedate', function () {
+            return function (input) {
+                if (input === undefined) {
+                    return "";
+                }
+                var then = utcDate(input);
+                var now = new Date();
+                var hoursSince = Math.round(Math.abs(now - then) / 1000 / 60 / 60);
+                var minutesSince = Math.round(Math.abs(now - then) / 60 / 1000);
+                var secondsSince = Math.round(Math.abs(now - then) / 1000);
+                if (secondsSince >= 1 && secondsSince < 60) {
+                    return secondsSince + ' ' + (secondsSince === 1 ? 'second' : 'seconds') + ' ago';
+                } else if (minutesSince >= 1 && minutesSince < 60) {
+                    return minutesSince + ' ' + (minutesSince === 1 ? 'minute' : 'minutes') + ' ago';
+                } else {
+                    var hours = ("0" + then.getHours()).slice(-2);
+                    var minutes = ("0" + then.getMinutes()).slice(-2);
+                    var month = ("0" + (then.getMonth() + 1)).slice(-2);
+                    var day = ("0" + then.getDate()).slice(-2);
+                    var year = then.getYear();
+                    return 'at ' + (year + 1900) + '-' + month + '-' + day + ' ' + hours + ':' + minutes;
+                }
+            }
+        })
+
+        .filter('timesinceepoch', function () {
+            return function (input) {
+                if (input === undefined) {
+                    return "";
+                }
+                var then = new Date(input * 1000);
+                var now = new Date();
+                var hoursSince = Math.round(Math.abs(now - then) / 1000 / 60 / 60);
+                var minutesSince = Math.round(Math.abs(now - then) / 60 / 1000);
+                var secondsSince = Math.round(Math.abs(now - then) / 1000);
+                if (secondsSince >= 1 && secondsSince < 60) {
+                    return secondsSince + ' ' + (secondsSince === 1 ? 'second' : 'seconds') + ' ago';
+                } else if (minutesSince >= 1 && minutesSince < 60) {
+                    return minutesSince + ' ' + (minutesSince === 1 ? 'minute' : 'minutes') + ' ago';
+                } else {
+                    var hours = ("0" + then.getHours()).slice(-2);
+                    var minutes = ("0" + then.getMinutes()).slice(-2);
+                    var month = ("0" + then.getMonth()).slice(-2);
+                    var day = ("0" + then.getDay()).slice(-2);
+                    var year = then.getYear();
+                    return 'at ' + (year + 1900) + '-' + month + '-' + day + ' ' + hours + ':' + minutes;
+                }
+            }
+        })
+
+        .filter('utcToBrowser', function () {
+            return function (input) {
+                if (input === undefined) {
+                    return "";
+                }
+                var d = new Date(input + " UTC");
+
+                var day = d.getDate();
+                var month = d.getMonth();
+                var year = d.getFullYear();
+
+                return day + ' ' + month+1 + ' ' + year;
+            }
+        })
+
+        .filter('rawHTML', ['$sce', function ($sce) {
+            return function (input) {
+                return $sce.trustAsHtml(input);
+            }
+        }])
+
+        .filter('roundCeil', function () {
+            return function (input) {
+                return Math.ceil(input);
+            }
+        });
+})();

+ 73 - 0
src/pineapple/js/helpers.js

@@ -0,0 +1,73 @@
+function registerController(name, controller) {
+    angular.module('pineapple').controllerProvider.register(name, controller);
+}
+
+function resizeModuleContent() {
+    var offset = 50;
+    var height = ((window.innerHeight > 0) ? window.innerHeight : screen.height) - 1;
+    height = height - offset;
+    if (height < 1) height = 1;
+    if (height > offset) {
+        $(".module-content").css("min-height", (height) + "px");
+    }
+}
+
+function collapseNavBar() {
+    var width = (window.innerWidth > 0) ? window.innerWidth : screen.width;
+    if (width < 768) {
+        $('div.navbar-collapse').removeClass('in');
+    } else {
+        $('div.navbar-collapse').addClass('in');
+    }
+}
+
+function convertMACAddress(mac) {
+    var pattern = /([-: ])/igm;
+    return mac.replace(pattern, ":");
+}
+
+function locallyAssigned(mac) {
+    return (parseInt('0x' + mac.split(':')[0]) & 0x02) !== 0;
+}
+
+function annotateMacs() {
+    var mac_rows = $('td, .autoselect').filter(
+        function() {
+            return /^[0-9a-f]{1,2}([.:-])[0-9a-f]{1,2}(?:\1[0-9a-f]{1,2}){4}$/i.test(this.textContent.trim());
+        });
+    mac_rows.filter(function() {
+            return locallyAssigned(this.textContent.trim());
+        }).prop('title', 'This MAC was likely locally assigned and was not assigned by the hardware vendor. This could be the result of MAC randomization, Spoofing, or a vendor that has not registered with the IEEE Registration Authority.').css('color', '#31708f');
+    mac_rows.filter(function() {
+            return !locallyAssigned(this.textContent.trim());
+        }).prop('title', 'This MAC was likely globally assigned by the hardware vendor. It has probably not been randomized for privacy.');
+}
+
+function utcDate(timestampStr) {
+    var a = timestampStr.split(' ');
+    var dmy = a[0].split('-');
+    var hms = a[1].split(':');
+
+    return new Date(Date.UTC(dmy[0], dmy[1] - 1, dmy[2], hms[0], hms[1], hms[2]));
+}
+
+function selectElement(elem) {
+    var selectRange = document.createRange();
+    selectRange.selectNodeContents(elem);
+    var selection = window.getSelection();
+    selection.removeAllRanges();
+    selection.addRange(selectRange);
+}
+
+$('html').click(function(e){
+    var elem = e.toElement;
+    if (elem !== undefined && elem.classList.contains('autoselect')) {
+        selectElement(elem);
+    }
+});
+
+$(window).resize(function() {
+    resizeModuleContent();
+});
+
+setInterval(annotateMacs, 1500);

+ 32 - 0
src/pineapple/js/pineapple.js

@@ -0,0 +1,32 @@
+(function(){
+    var pineapple = angular.module('pineapple', ['ngRoute', 'ngCookies'])
+
+    .config(['$routeProvider', '$controllerProvider', '$compileProvider', '$filterProvider', '$provide', function($routeProvider, $controllerProvider, $compileProvider, $filterProvider, $provide) {
+        
+        pineapple.controllerProvider = $controllerProvider;
+        pineapple.compileProvider    = $compileProvider;
+        pineapple.routeProvider      = $routeProvider;
+        pineapple.filterProvider     = $filterProvider;
+        pineapple.provide            = $provide;
+    }])
+    .run(['$api', function($api){
+        pineapple.routeProvider
+        .when('/modules/:moduleName', {
+            templateUrl: function(params) {
+                return 'modules/'+ params.moduleName +'/module.html';
+            },
+            controller: function() {
+                resizeModuleContent();
+                collapseNavBar();
+            },
+            resolve: {
+                jsLoader: ['$route', function($route) {
+                    return $.getScript('modules/'+ $route.current.params.moduleName +'/js/module.js');
+                }]
+            }
+        })
+        .otherwise({
+            redirectTo: '/modules/Dashboard'
+        });
+    }])
+})();

+ 109 - 0
src/pineapple/js/services.js

@@ -0,0 +1,109 @@
+(function(){
+    angular.module('pineapple')
+    .service('$api', ['$http', function($http){
+        this.navbarReloader = false;
+        this.device = undefined;
+        this.deviceCallbacks = [];
+
+        this.request = (function(data, callback, scope) {
+
+            return $http.post('/api/', data).
+            then(function(response){
+                if (response.data.error === "Not Authenticated") {
+                    if (response.data.setupRequired === true) {
+                        if (window.location.hash !== "#!/modules/Setup") {
+                            window.location.hash = "#!/modules/Setup";
+                        }
+                    } else {
+                        $("#loginModal").modal({
+                            show: true,
+                            keyboard: false,
+                            backdrop: 'static'
+                        });
+                    }
+                    $(".logout").hide();
+                }
+                if (callback !== undefined) {
+                    if (scope !== undefined) {
+                        callback(response.data, scope);
+                    } else {
+                        callback(response.data);
+                    }
+                }
+            }, function(response) {
+                callback({error: 'HTTP Error', HTTPError: response.statusText, HTTPCode: response.status});
+            });
+        });
+
+        this.login = (function(user, pass, callback){
+            return this.request({system: 'authentication', action: 'login', username: user, password: pass, time: Math.floor((new Date).getTime()/1000)}, function(data){
+                callback(data);
+            }, this);
+        });
+        this.logout = (function(callback){
+            return this.request({system: 'authentication', action: 'logout'}, callback);
+        });
+
+
+        this.registerNavbar = (function(reloader) {
+            this.navbarReloader = reloader;
+        });
+
+        this.reloadNavbar = (function() {
+            this.navbarReloader();
+        });
+
+
+        this.checkAuth = (function(callback){
+            return this.request({system: 'authentication', action: 'checkAuth'}, function(data){
+                if (callback !== undefined) {
+                    callback(data);
+                }
+            });
+        });
+
+        this.getNotifications = function(callback){
+            this.request({
+                system: 'notifications',
+                action: 'listNotifications'
+            }, function(data) {
+                callback(data);
+            });
+        };
+        this.clearNotifications = function(){
+            this.request({
+                system: 'notifications',
+                action: 'clearNotifications'
+            });
+        };
+        this.addNotification = function(notificationMessage){
+            this.request({
+                system: 'notifications',
+                action: 'addNotification',
+                message: notificationMessage
+            });
+        };
+
+        this.onDeviceIdentified = function(callback, scope){
+            this.deviceCallbacks.push({callback: callback, scope: scope});
+            if (this.device !== undefined) {
+                for (var i = this.deviceCallbacks.length-1; i >=0; --i) {
+                    this.deviceCallbacks[i].callback(this.device, this.deviceCallbacks[i].scope);
+                }
+            }
+        };
+
+        this.request({
+            module: 'Configuration',
+            action: 'getDevice'
+        }, function(response, scope){
+            scope.device = response.device;
+            for (var i = scope.deviceCallbacks.length-1; i >=0; --i) {
+                var callbackObj = scope.deviceCallbacks[i];
+                callbackObj.callback(scope.device, callbackObj.scope);
+            }
+        }, this);
+
+        this.checkAuth();
+    }]);
+})();

+ 9 - 0
src/pineapple/js/vendor/angular-cookies.min.js

@@ -0,0 +1,9 @@
+/*
+ AngularJS v1.6.6
+ (c) 2010-2017 Google, Inc. http://angularjs.org
+ License: MIT
+*/
+(function(n,c){'use strict';function l(b,a,g){var d=g.baseHref(),k=b[0];return function(b,e,f){var g,h;f=f||{};h=f.expires;g=c.isDefined(f.path)?f.path:d;c.isUndefined(e)&&(h="Thu, 01 Jan 1970 00:00:00 GMT",e="");c.isString(h)&&(h=new Date(h));e=encodeURIComponent(b)+"="+encodeURIComponent(e);e=e+(g?";path="+g:"")+(f.domain?";domain="+f.domain:"");e+=h?";expires="+h.toUTCString():"";e+=f.secure?";secure":"";f=e.length+1;4096<f&&a.warn("Cookie '"+b+"' possibly not set or overflowed because it was too large ("+
+f+" > 4096 bytes)!");k.cookie=e}}c.module("ngCookies",["ng"]).info({angularVersion:"1.6.6"}).provider("$cookies",[function(){var b=this.defaults={};this.$get=["$$cookieReader","$$cookieWriter",function(a,g){return{get:function(d){return a()[d]},getObject:function(d){return(d=this.get(d))?c.fromJson(d):d},getAll:function(){return a()},put:function(d,a,m){g(d,a,m?c.extend({},b,m):b)},putObject:function(d,b,a){this.put(d,c.toJson(b),a)},remove:function(a,k){g(a,void 0,k?c.extend({},b,k):b)}}}]}]);c.module("ngCookies").factory("$cookieStore",
+["$cookies",function(b){return{get:function(a){return b.getObject(a)},put:function(a,c){b.putObject(a,c)},remove:function(a){b.remove(a)}}}]);l.$inject=["$document","$log","$browser"];c.module("ngCookies").provider("$$cookieWriter",function(){this.$get=l})})(window,window.angular);
+//# sourceMappingURL=angular-cookies.min.js.map

+ 17 - 0
src/pineapple/js/vendor/angular-route.min.js

@@ -0,0 +1,17 @@
+/*
+ AngularJS v1.6.6
+ (c) 2010-2017 Google, Inc. http://angularjs.org
+ License: MIT
+*/
+(function(J,d){'use strict';function A(d){k&&d.get("$route")}function B(t,u,g){return{restrict:"ECA",terminal:!0,priority:400,transclude:"element",link:function(a,f,b,c,m){function v(){l&&(g.cancel(l),l=null);n&&(n.$destroy(),n=null);p&&(l=g.leave(p),l.done(function(a){!1!==a&&(l=null)}),p=null)}function E(){var b=t.current&&t.current.locals;if(d.isDefined(b&&b.$template)){var b=a.$new(),c=t.current;p=m(b,function(b){g.enter(b,null,p||f).done(function(b){!1===b||!d.isDefined(w)||w&&!a.$eval(w)||u()});
+v()});n=c.scope=b;n.$emit("$viewContentLoaded");n.$eval(k)}else v()}var n,p,l,w=b.autoscroll,k=b.onload||"";a.$on("$routeChangeSuccess",E);E()}}}function C(d,k,g){return{restrict:"ECA",priority:-400,link:function(a,f){var b=g.current,c=b.locals;f.html(c.$template);var m=d(f.contents());if(b.controller){c.$scope=a;var v=k(b.controller,c);b.controllerAs&&(a[b.controllerAs]=v);f.data("$ngControllerController",v);f.children().data("$ngControllerController",v)}a[b.resolveAs||"$resolve"]=c;m(a)}}}var x,
+y,F,G,z=d.module("ngRoute",[]).info({angularVersion:"1.6.6"}).provider("$route",function(){function t(a,f){return d.extend(Object.create(a),f)}function u(a,d){var b=d.caseInsensitiveMatch,c={originalPath:a,regexp:a},g=c.keys=[];a=a.replace(/([().])/g,"\\$1").replace(/(\/)?:(\w+)(\*\?|[?*])?/g,function(a,b,d,c){a="?"===c||"*?"===c?"?":null;c="*"===c||"*?"===c?"*":null;g.push({name:d,optional:!!a});b=b||"";return""+(a?"":b)+"(?:"+(a?b:"")+(c&&"(.+?)"||"([^/]+)")+(a||"")+")"+(a||"")}).replace(/([/$*])/g,
+"\\$1");c.regexp=new RegExp("^"+a+"$",b?"i":"");return c}x=d.isArray;y=d.isObject;F=d.isDefined;G=d.noop;var g={};this.when=function(a,f){var b;b=void 0;if(x(f)){b=b||[];for(var c=0,m=f.length;c<m;c++)b[c]=f[c]}else if(y(f))for(c in b=b||{},f)if("$"!==c.charAt(0)||"$"!==c.charAt(1))b[c]=f[c];b=b||f;d.isUndefined(b.reloadOnSearch)&&(b.reloadOnSearch=!0);d.isUndefined(b.caseInsensitiveMatch)&&(b.caseInsensitiveMatch=this.caseInsensitiveMatch);g[a]=d.extend(b,a&&u(a,b));a&&(c="/"===a[a.length-1]?a.substr(0,
+a.length-1):a+"/",g[c]=d.extend({redirectTo:a},u(c,b)));return this};this.caseInsensitiveMatch=!1;this.otherwise=function(a){"string"===typeof a&&(a={redirectTo:a});this.when(null,a);return this};k=!0;this.eagerInstantiationEnabled=function(a){return F(a)?(k=a,this):k};this.$get=["$rootScope","$location","$routeParams","$q","$injector","$templateRequest","$sce","$browser",function(a,f,b,c,m,k,u,n){function p(e){var h=q.current;(y=(s=C())&&h&&s.$$route===h.$$route&&d.equals(s.pathParams,h.pathParams)&&
+!s.reloadOnSearch&&!D)||!h&&!s||a.$broadcast("$routeChangeStart",s,h).defaultPrevented&&e&&e.preventDefault()}function l(){var e=q.current,h=s;if(y)e.params=h.params,d.copy(e.params,b),a.$broadcast("$routeUpdate",e);else if(h||e){D=!1;q.current=h;var H=c.resolve(h);n.$$incOutstandingRequestCount();H.then(w).then(z).then(function(c){return c&&H.then(A).then(function(c){h===q.current&&(h&&(h.locals=c,d.copy(h.params,b)),a.$broadcast("$routeChangeSuccess",h,e))})}).catch(function(b){h===q.current&&a.$broadcast("$routeChangeError",
+h,e,b)}).finally(function(){n.$$completeOutstandingRequest(G)})}}function w(e){var a={route:e,hasRedirection:!1};if(e)if(e.redirectTo)if(d.isString(e.redirectTo))a.path=x(e.redirectTo,e.params),a.search=e.params,a.hasRedirection=!0;else{var b=f.path(),g=f.search();e=e.redirectTo(e.pathParams,b,g);d.isDefined(e)&&(a.url=e,a.hasRedirection=!0)}else if(e.resolveRedirectTo)return c.resolve(m.invoke(e.resolveRedirectTo)).then(function(e){d.isDefined(e)&&(a.url=e,a.hasRedirection=!0);return a});return a}
+function z(a){var b=!0;if(a.route!==q.current)b=!1;else if(a.hasRedirection){var d=f.url(),c=a.url;c?f.url(c).replace():c=f.path(a.path).search(a.search).replace().url();c!==d&&(b=!1)}return b}function A(a){if(a){var b=d.extend({},a.resolve);d.forEach(b,function(a,e){b[e]=d.isString(a)?m.get(a):m.invoke(a,null,null,e)});a=B(a);d.isDefined(a)&&(b.$template=a);return c.all(b)}}function B(a){var b,c;d.isDefined(b=a.template)?d.isFunction(b)&&(b=b(a.params)):d.isDefined(c=a.templateUrl)&&(d.isFunction(c)&&
+(c=c(a.params)),d.isDefined(c)&&(a.loadedTemplateUrl=u.valueOf(c),b=k(c)));return b}function C(){var a,b;d.forEach(g,function(c,g){var r;if(r=!b){var k=f.path();r=c.keys;var m={};if(c.regexp)if(k=c.regexp.exec(k)){for(var l=1,n=k.length;l<n;++l){var p=r[l-1],q=k[l];p&&q&&(m[p.name]=q)}r=m}else r=null;else r=null;r=a=r}r&&(b=t(c,{params:d.extend({},f.search(),a),pathParams:a}),b.$$route=c)});return b||g[null]&&t(g[null],{params:{},pathParams:{}})}function x(a,b){var c=[];d.forEach((a||"").split(":"),
+function(a,d){if(0===d)c.push(a);else{var e=a.match(/(\w+)(?:[?*])?(.*)/),f=e[1];c.push(b[f]);c.push(e[2]||"");delete b[f]}});return c.join("")}var D=!1,s,y,q={routes:g,reload:function(){D=!0;var b={defaultPrevented:!1,preventDefault:function(){this.defaultPrevented=!0;D=!1}};a.$evalAsync(function(){p(b);b.defaultPrevented||l()})},updateParams:function(a){if(this.current&&this.current.$$route)a=d.extend({},this.current.params,a),f.path(x(this.current.$$route.originalPath,a)),f.search(a);else throw I("norout");
+}};a.$on("$locationChangeStart",p);a.$on("$locationChangeSuccess",l);return q}]}).run(A),I=d.$$minErr("ngRoute"),k;A.$inject=["$injector"];z.provider("$routeParams",function(){this.$get=function(){return{}}});z.directive("ngView",B);z.directive("ngView",C);B.$inject=["$route","$anchorScroll","$animate"];C.$inject=["$compile","$controller","$route"]})(window,window.angular);
+//# sourceMappingURL=angular-route.min.js.map

+ 335 - 0
src/pineapple/js/vendor/angular.min.js

@@ -0,0 +1,335 @@
+/*
+ AngularJS v1.6.6
+ (c) 2010-2017 Google, Inc. http://angularjs.org
+ License: MIT
+*/
+(function(u){'use strict';function oe(a){if(E(a))t(a.objectMaxDepth)&&(Lc.objectMaxDepth=Ub(a.objectMaxDepth)?a.objectMaxDepth:NaN);else return Lc}function Ub(a){return Y(a)&&0<a}function M(a,b){b=b||Error;return function(){var d=arguments[0],c;c="["+(a?a+":":"")+d+"] http://errors.angularjs.org/1.6.6/"+(a?a+"/":"")+d;for(d=1;d<arguments.length;d++){c=c+(1==d?"?":"&")+"p"+(d-1)+"=";var e=encodeURIComponent,f;f=arguments[d];f="function"==typeof f?f.toString().replace(/ \{[\s\S]*$/,""):"undefined"==
+typeof f?"undefined":"string"!=typeof f?JSON.stringify(f):f;c+=e(f)}return new b(c)}}function xa(a){if(null==a||$a(a))return!1;if(I(a)||D(a)||B&&a instanceof B)return!0;var b="length"in Object(a)&&a.length;return Y(b)&&(0<=b&&(b-1 in a||a instanceof Array)||"function"===typeof a.item)}function p(a,b,d){var c,e;if(a)if(A(a))for(c in a)"prototype"!==c&&"length"!==c&&"name"!==c&&a.hasOwnProperty(c)&&b.call(d,a[c],c,a);else if(I(a)||xa(a)){var f="object"!==typeof a;c=0;for(e=a.length;c<e;c++)(f||c in
+a)&&b.call(d,a[c],c,a)}else if(a.forEach&&a.forEach!==p)a.forEach(b,d,a);else if(Mc(a))for(c in a)b.call(d,a[c],c,a);else if("function"===typeof a.hasOwnProperty)for(c in a)a.hasOwnProperty(c)&&b.call(d,a[c],c,a);else for(c in a)ra.call(a,c)&&b.call(d,a[c],c,a);return a}function Nc(a,b,d){for(var c=Object.keys(a).sort(),e=0;e<c.length;e++)b.call(d,a[c[e]],c[e]);return c}function Vb(a){return function(b,d){a(d,b)}}function pe(){return++sb}function Wb(a,b,d){for(var c=a.$$hashKey,e=0,f=b.length;e<f;++e){var g=
+b[e];if(E(g)||A(g))for(var k=Object.keys(g),h=0,l=k.length;h<l;h++){var m=k[h],n=g[m];d&&E(n)?ea(n)?a[m]=new Date(n.valueOf()):ab(n)?a[m]=new RegExp(n):n.nodeName?a[m]=n.cloneNode(!0):Xb(n)?a[m]=n.clone():(E(a[m])||(a[m]=I(n)?[]:{}),Wb(a[m],[n],!0)):a[m]=n}}c?a.$$hashKey=c:delete a.$$hashKey;return a}function P(a){return Wb(a,ya.call(arguments,1),!1)}function qe(a){return Wb(a,ya.call(arguments,1),!0)}function Z(a){return parseInt(a,10)}function Yb(a,b){return P(Object.create(a),b)}function C(){}
+function bb(a){return a}function ka(a){return function(){return a}}function Zb(a){return A(a.toString)&&a.toString!==ha}function w(a){return"undefined"===typeof a}function t(a){return"undefined"!==typeof a}function E(a){return null!==a&&"object"===typeof a}function Mc(a){return null!==a&&"object"===typeof a&&!Oc(a)}function D(a){return"string"===typeof a}function Y(a){return"number"===typeof a}function ea(a){return"[object Date]"===ha.call(a)}function $b(a){switch(ha.call(a)){case "[object Error]":return!0;
+case "[object Exception]":return!0;case "[object DOMException]":return!0;default:return a instanceof Error}}function A(a){return"function"===typeof a}function ab(a){return"[object RegExp]"===ha.call(a)}function $a(a){return a&&a.window===a}function cb(a){return a&&a.$evalAsync&&a.$watch}function Na(a){return"boolean"===typeof a}function re(a){return a&&Y(a.length)&&se.test(ha.call(a))}function Xb(a){return!(!a||!(a.nodeName||a.prop&&a.attr&&a.find))}function te(a){var b={};a=a.split(",");var d;for(d=
+0;d<a.length;d++)b[a[d]]=!0;return b}function za(a){return N(a.nodeName||a[0]&&a[0].nodeName)}function db(a,b){var d=a.indexOf(b);0<=d&&a.splice(d,1);return d}function pa(a,b,d){function c(a,b,c){c--;if(0>c)return"...";var d=b.$$hashKey,g;if(I(a)){g=0;for(var f=a.length;g<f;g++)b.push(e(a[g],c))}else if(Mc(a))for(g in a)b[g]=e(a[g],c);else if(a&&"function"===typeof a.hasOwnProperty)for(g in a)a.hasOwnProperty(g)&&(b[g]=e(a[g],c));else for(g in a)ra.call(a,g)&&(b[g]=e(a[g],c));d?b.$$hashKey=d:delete b.$$hashKey;
+return b}function e(a,b){if(!E(a))return a;var d=g.indexOf(a);if(-1!==d)return k[d];if($a(a)||cb(a))throw qa("cpws");var d=!1,e=f(a);void 0===e&&(e=I(a)?[]:Object.create(Oc(a)),d=!0);g.push(a);k.push(e);return d?c(a,e,b):e}function f(a){switch(ha.call(a)){case "[object Int8Array]":case "[object Int16Array]":case "[object Int32Array]":case "[object Float32Array]":case "[object Float64Array]":case "[object Uint8Array]":case "[object Uint8ClampedArray]":case "[object Uint16Array]":case "[object Uint32Array]":return new a.constructor(e(a.buffer),
+a.byteOffset,a.length);case "[object ArrayBuffer]":if(!a.slice){var b=new ArrayBuffer(a.byteLength);(new Uint8Array(b)).set(new Uint8Array(a));return b}return a.slice(0);case "[object Boolean]":case "[object Number]":case "[object String]":case "[object Date]":return new a.constructor(a.valueOf());case "[object RegExp]":return b=new RegExp(a.source,a.toString().match(/[^/]*$/)[0]),b.lastIndex=a.lastIndex,b;case "[object Blob]":return new a.constructor([a],{type:a.type})}if(A(a.cloneNode))return a.cloneNode(!0)}
+var g=[],k=[];d=Ub(d)?d:NaN;if(b){if(re(b)||"[object ArrayBuffer]"===ha.call(b))throw qa("cpta");if(a===b)throw qa("cpi");I(b)?b.length=0:p(b,function(a,c){"$$hashKey"!==c&&delete b[c]});g.push(a);k.push(b);return c(a,b,d)}return e(a,d)}function ac(a,b){return a===b||a!==a&&b!==b}function sa(a,b){if(a===b)return!0;if(null===a||null===b)return!1;if(a!==a&&b!==b)return!0;var d=typeof a,c;if(d===typeof b&&"object"===d)if(I(a)){if(!I(b))return!1;if((d=a.length)===b.length){for(c=0;c<d;c++)if(!sa(a[c],
+b[c]))return!1;return!0}}else{if(ea(a))return ea(b)?ac(a.getTime(),b.getTime()):!1;if(ab(a))return ab(b)?a.toString()===b.toString():!1;if(cb(a)||cb(b)||$a(a)||$a(b)||I(b)||ea(b)||ab(b))return!1;d=S();for(c in a)if("$"!==c.charAt(0)&&!A(a[c])){if(!sa(a[c],b[c]))return!1;d[c]=!0}for(c in b)if(!(c in d)&&"$"!==c.charAt(0)&&t(b[c])&&!A(b[c]))return!1;return!0}return!1}function eb(a,b,d){return a.concat(ya.call(b,d))}function Ra(a,b){var d=2<arguments.length?ya.call(arguments,2):[];return!A(b)||b instanceof
+RegExp?b:d.length?function(){return arguments.length?b.apply(a,eb(d,arguments,0)):b.apply(a,d)}:function(){return arguments.length?b.apply(a,arguments):b.call(a)}}function Pc(a,b){var d=b;"string"===typeof a&&"$"===a.charAt(0)&&"$"===a.charAt(1)?d=void 0:$a(b)?d="$WINDOW":b&&u.document===b?d="$DOCUMENT":cb(b)&&(d="$SCOPE");return d}function fb(a,b){if(!w(a))return Y(b)||(b=b?2:null),JSON.stringify(a,Pc,b)}function Qc(a){return D(a)?JSON.parse(a):a}function Rc(a,b){a=a.replace(ue,"");var d=Date.parse("Jan 01, 1970 00:00:00 "+
+a)/6E4;return T(d)?b:d}function bc(a,b,d){d=d?-1:1;var c=a.getTimezoneOffset();b=Rc(b,c);d*=b-c;a=new Date(a.getTime());a.setMinutes(a.getMinutes()+d);return a}function Aa(a){a=B(a).clone().empty();var b=B("<div>").append(a).html();try{return a[0].nodeType===Oa?N(b):b.match(/^(<[^>]+>)/)[1].replace(/^<([\w-]+)/,function(a,b){return"<"+N(b)})}catch(d){return N(b)}}function Sc(a){try{return decodeURIComponent(a)}catch(b){}}function Tc(a){var b={};p((a||"").split("&"),function(a){var c,e,f;a&&(e=a=a.replace(/\+/g,
+"%20"),c=a.indexOf("="),-1!==c&&(e=a.substring(0,c),f=a.substring(c+1)),e=Sc(e),t(e)&&(f=t(f)?Sc(f):!0,ra.call(b,e)?I(b[e])?b[e].push(f):b[e]=[b[e],f]:b[e]=f))});return b}function cc(a){var b=[];p(a,function(a,c){I(a)?p(a,function(a){b.push(ia(c,!0)+(!0===a?"":"="+ia(a,!0)))}):b.push(ia(c,!0)+(!0===a?"":"="+ia(a,!0)))});return b.length?b.join("&"):""}function gb(a){return ia(a,!0).replace(/%26/gi,"&").replace(/%3D/gi,"=").replace(/%2B/gi,"+")}function ia(a,b){return encodeURIComponent(a).replace(/%40/gi,
+"@").replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%3B/gi,";").replace(/%20/g,b?"%20":"+")}function ve(a,b){var d,c,e=Ha.length;for(c=0;c<e;++c)if(d=Ha[c]+b,D(d=a.getAttribute(d)))return d;return null}function we(a,b){var d,c,e={};p(Ha,function(b){b+="app";!d&&a.hasAttribute&&a.hasAttribute(b)&&(d=a,c=a.getAttribute(b))});p(Ha,function(b){b+="app";var e;!d&&(e=a.querySelector("["+b.replace(":","\\:")+"]"))&&(d=e,c=e.getAttribute(b))});d&&(xe?(e.strictDi=null!==ve(d,"strict-di"),
+b(d,c?[c]:[],e)):u.console.error("Angular: disabling automatic bootstrap. <script> protocol indicates an extension, document.location.href does not match."))}function Uc(a,b,d){E(d)||(d={});d=P({strictDi:!1},d);var c=function(){a=B(a);if(a.injector()){var c=a[0]===u.document?"document":Aa(a);throw qa("btstrpd",c.replace(/</,"&lt;").replace(/>/,"&gt;"));}b=b||[];b.unshift(["$provide",function(b){b.value("$rootElement",a)}]);d.debugInfoEnabled&&b.push(["$compileProvider",function(a){a.debugInfoEnabled(!0)}]);
+b.unshift("ng");c=hb(b,d.strictDi);c.invoke(["$rootScope","$rootElement","$compile","$injector",function(a,b,c,d){a.$apply(function(){b.data("$injector",d);c(b)(a)})}]);return c},e=/^NG_ENABLE_DEBUG_INFO!/,f=/^NG_DEFER_BOOTSTRAP!/;u&&e.test(u.name)&&(d.debugInfoEnabled=!0,u.name=u.name.replace(e,""));if(u&&!f.test(u.name))return c();u.name=u.name.replace(f,"");$.resumeBootstrap=function(a){p(a,function(a){b.push(a)});return c()};A($.resumeDeferredBootstrap)&&$.resumeDeferredBootstrap()}function ye(){u.name=
+"NG_ENABLE_DEBUG_INFO!"+u.name;u.location.reload()}function ze(a){a=$.element(a).injector();if(!a)throw qa("test");return a.get("$$testability")}function Vc(a,b){b=b||"_";return a.replace(Ae,function(a,c){return(c?b:"")+a.toLowerCase()})}function Be(){var a;if(!Wc){var b=tb();(la=w(b)?u.jQuery:b?u[b]:void 0)&&la.fn.on?(B=la,P(la.fn,{scope:Sa.scope,isolateScope:Sa.isolateScope,controller:Sa.controller,injector:Sa.injector,inheritedData:Sa.inheritedData}),a=la.cleanData,la.cleanData=function(b){for(var c,
+e=0,f;null!=(f=b[e]);e++)(c=la._data(f,"events"))&&c.$destroy&&la(f).triggerHandler("$destroy");a(b)}):B=U;$.element=B;Wc=!0}}function ib(a,b,d){if(!a)throw qa("areq",b||"?",d||"required");return a}function ub(a,b,d){d&&I(a)&&(a=a[a.length-1]);ib(A(a),b,"not a function, got "+(a&&"object"===typeof a?a.constructor.name||"Object":typeof a));return a}function Ia(a,b){if("hasOwnProperty"===a)throw qa("badname",b);}function Xc(a,b,d){if(!b)return a;b=b.split(".");for(var c,e=a,f=b.length,g=0;g<f;g++)c=
+b[g],a&&(a=(e=a)[c]);return!d&&A(a)?Ra(e,a):a}function vb(a){for(var b=a[0],d=a[a.length-1],c,e=1;b!==d&&(b=b.nextSibling);e++)if(c||a[e]!==b)c||(c=B(ya.call(a,0,e))),c.push(b);return c||a}function S(){return Object.create(null)}function dc(a){if(null==a)return"";switch(typeof a){case "string":break;case "number":a=""+a;break;default:a=!Zb(a)||I(a)||ea(a)?fb(a):a.toString()}return a}function Ce(a){function b(a,b,c){return a[b]||(a[b]=c())}var d=M("$injector"),c=M("ng");a=b(a,"angular",Object);a.$$minErr=
+a.$$minErr||M;return b(a,"module",function(){var a={};return function(f,g,k){var h={};if("hasOwnProperty"===f)throw c("badname","module");g&&a.hasOwnProperty(f)&&(a[f]=null);return b(a,f,function(){function a(b,c,d,g){g||(g=e);return function(){g[d||"push"]([b,c,arguments]);return p}}function b(a,c,d){d||(d=e);return function(b,e){e&&A(e)&&(e.$$moduleName=f);d.push([a,c,arguments]);return p}}if(!g)throw d("nomod",f);var e=[],q=[],G=[],L=a("$injector","invoke","push",q),p={_invokeQueue:e,_configBlocks:q,
+_runBlocks:G,info:function(a){if(t(a)){if(!E(a))throw c("aobj","value");h=a;return this}return h},requires:g,name:f,provider:b("$provide","provider"),factory:b("$provide","factory"),service:b("$provide","service"),value:a("$provide","value"),constant:a("$provide","constant","unshift"),decorator:b("$provide","decorator",q),animation:b("$animateProvider","register"),filter:b("$filterProvider","register"),controller:b("$controllerProvider","register"),directive:b("$compileProvider","directive"),component:b("$compileProvider",
+"component"),config:L,run:function(a){G.push(a);return this}};k&&L(k);return p})}})}function ja(a,b){if(I(a)){b=b||[];for(var d=0,c=a.length;d<c;d++)b[d]=a[d]}else if(E(a))for(d in b=b||{},a)if("$"!==d.charAt(0)||"$"!==d.charAt(1))b[d]=a[d];return b||a}function De(a,b){var d=[];Ub(b)&&(a=$.copy(a,null,b));return JSON.stringify(a,function(a,b){b=Pc(a,b);if(E(b)){if(0<=d.indexOf(b))return"...";d.push(b)}return b})}function Ee(a){P(a,{errorHandlingConfig:oe,bootstrap:Uc,copy:pa,extend:P,merge:qe,equals:sa,
+element:B,forEach:p,injector:hb,noop:C,bind:Ra,toJson:fb,fromJson:Qc,identity:bb,isUndefined:w,isDefined:t,isString:D,isFunction:A,isObject:E,isNumber:Y,isElement:Xb,isArray:I,version:Fe,isDate:ea,lowercase:N,uppercase:wb,callbacks:{$$counter:0},getTestability:ze,reloadWithDebugInfo:ye,$$minErr:M,$$csp:Ja,$$encodeUriSegment:gb,$$encodeUriQuery:ia,$$stringify:dc});ec=Ce(u);ec("ng",["ngLocale"],["$provide",function(a){a.provider({$$sanitizeUri:Ge});a.provider("$compile",Yc).directive({a:He,input:Zc,
+textarea:Zc,form:Ie,script:Je,select:Ke,option:Le,ngBind:Me,ngBindHtml:Ne,ngBindTemplate:Oe,ngClass:Pe,ngClassEven:Qe,ngClassOdd:Re,ngCloak:Se,ngController:Te,ngForm:Ue,ngHide:Ve,ngIf:We,ngInclude:Xe,ngInit:Ye,ngNonBindable:Ze,ngPluralize:$e,ngRepeat:af,ngShow:bf,ngStyle:cf,ngSwitch:df,ngSwitchWhen:ef,ngSwitchDefault:ff,ngOptions:gf,ngTransclude:hf,ngModel:jf,ngList:kf,ngChange:lf,pattern:$c,ngPattern:$c,required:ad,ngRequired:ad,minlength:bd,ngMinlength:bd,maxlength:cd,ngMaxlength:cd,ngValue:mf,
+ngModelOptions:nf}).directive({ngInclude:of}).directive(xb).directive(dd);a.provider({$anchorScroll:pf,$animate:qf,$animateCss:rf,$$animateJs:sf,$$animateQueue:tf,$$AnimateRunner:uf,$$animateAsyncRun:vf,$browser:wf,$cacheFactory:xf,$controller:yf,$document:zf,$$isDocumentHidden:Af,$exceptionHandler:Bf,$filter:ed,$$forceReflow:Cf,$interpolate:Df,$interval:Ef,$http:Ff,$httpParamSerializer:Gf,$httpParamSerializerJQLike:Hf,$httpBackend:If,$xhrFactory:Jf,$jsonpCallbacks:Kf,$location:Lf,$log:Mf,$parse:Nf,
+$rootScope:Of,$q:Pf,$$q:Qf,$sce:Rf,$sceDelegate:Sf,$sniffer:Tf,$templateCache:Uf,$templateRequest:Vf,$$testability:Wf,$timeout:Xf,$window:Yf,$$rAF:Zf,$$jqLite:$f,$$Map:ag,$$cookieReader:bg})}]).info({angularVersion:"1.6.6"})}function jb(a,b){return b.toUpperCase()}function yb(a){return a.replace(cg,jb)}function fc(a){a=a.nodeType;return 1===a||!a||9===a}function fd(a,b){var d,c,e=b.createDocumentFragment(),f=[];if(gc.test(a)){d=e.appendChild(b.createElement("div"));c=(dg.exec(a)||["",""])[1].toLowerCase();
+c=aa[c]||aa._default;d.innerHTML=c[1]+a.replace(eg,"<$1></$2>")+c[2];for(c=c[0];c--;)d=d.lastChild;f=eb(f,d.childNodes);d=e.firstChild;d.textContent=""}else f.push(b.createTextNode(a));e.textContent="";e.innerHTML="";p(f,function(a){e.appendChild(a)});return e}function U(a){if(a instanceof U)return a;var b;D(a)&&(a=Q(a),b=!0);if(!(this instanceof U)){if(b&&"<"!==a.charAt(0))throw hc("nosel");return new U(a)}if(b){b=u.document;var d;a=(d=fg.exec(a))?[b.createElement(d[1])]:(d=fd(a,b))?d.childNodes:
+[];ic(this,a)}else A(a)?gd(a):ic(this,a)}function jc(a){return a.cloneNode(!0)}function zb(a,b){!b&&fc(a)&&B.cleanData([a]);a.querySelectorAll&&B.cleanData(a.querySelectorAll("*"))}function hd(a,b,d,c){if(t(c))throw hc("offargs");var e=(c=Ab(a))&&c.events,f=c&&c.handle;if(f)if(b){var g=function(b){var c=e[b];t(d)&&db(c||[],d);t(d)&&c&&0<c.length||(a.removeEventListener(b,f),delete e[b])};p(b.split(" "),function(a){g(a);Bb[a]&&g(Bb[a])})}else for(b in e)"$destroy"!==b&&a.removeEventListener(b,f),delete e[b]}
+function kc(a,b){var d=a.ng339,c=d&&kb[d];c&&(b?delete c.data[b]:(c.handle&&(c.events.$destroy&&c.handle({},"$destroy"),hd(a)),delete kb[d],a.ng339=void 0))}function Ab(a,b){var d=a.ng339,d=d&&kb[d];b&&!d&&(a.ng339=d=++gg,d=kb[d]={events:{},data:{},handle:void 0});return d}function lc(a,b,d){if(fc(a)){var c,e=t(d),f=!e&&b&&!E(b),g=!b;a=(a=Ab(a,!f))&&a.data;if(e)a[yb(b)]=d;else{if(g)return a;if(f)return a&&a[yb(b)];for(c in b)a[yb(c)]=b[c]}}}function Cb(a,b){return a.getAttribute?-1<(" "+(a.getAttribute("class")||
+"")+" ").replace(/[\n\t]/g," ").indexOf(" "+b+" "):!1}function Db(a,b){b&&a.setAttribute&&p(b.split(" "),function(b){a.setAttribute("class",Q((" "+(a.getAttribute("class")||"")+" ").replace(/[\n\t]/g," ").replace(" "+Q(b)+" "," ")))})}function Eb(a,b){if(b&&a.setAttribute){var d=(" "+(a.getAttribute("class")||"")+" ").replace(/[\n\t]/g," ");p(b.split(" "),function(a){a=Q(a);-1===d.indexOf(" "+a+" ")&&(d+=a+" ")});a.setAttribute("class",Q(d))}}function ic(a,b){if(b)if(b.nodeType)a[a.length++]=b;else{var d=
+b.length;if("number"===typeof d&&b.window!==b){if(d)for(var c=0;c<d;c++)a[a.length++]=b[c]}else a[a.length++]=b}}function id(a,b){return Fb(a,"$"+(b||"ngController")+"Controller")}function Fb(a,b,d){9===a.nodeType&&(a=a.documentElement);for(b=I(b)?b:[b];a;){for(var c=0,e=b.length;c<e;c++)if(t(d=B.data(a,b[c])))return d;a=a.parentNode||11===a.nodeType&&a.host}}function jd(a){for(zb(a,!0);a.firstChild;)a.removeChild(a.firstChild)}function Gb(a,b){b||zb(a);var d=a.parentNode;d&&d.removeChild(a)}function hg(a,
+b){b=b||u;if("complete"===b.document.readyState)b.setTimeout(a);else B(b).on("load",a)}function gd(a){function b(){u.document.removeEventListener("DOMContentLoaded",b);u.removeEventListener("load",b);a()}"complete"===u.document.readyState?u.setTimeout(a):(u.document.addEventListener("DOMContentLoaded",b),u.addEventListener("load",b))}function kd(a,b){var d=Hb[b.toLowerCase()];return d&&ld[za(a)]&&d}function ig(a,b){var d=function(c,d){c.isDefaultPrevented=function(){return c.defaultPrevented};var f=
+b[d||c.type],g=f?f.length:0;if(g){if(w(c.immediatePropagationStopped)){var k=c.stopImmediatePropagation;c.stopImmediatePropagation=function(){c.immediatePropagationStopped=!0;c.stopPropagation&&c.stopPropagation();k&&k.call(c)}}c.isImmediatePropagationStopped=function(){return!0===c.immediatePropagationStopped};var h=f.specialHandlerWrapper||jg;1<g&&(f=ja(f));for(var l=0;l<g;l++)c.isImmediatePropagationStopped()||h(a,c,f[l])}};d.elem=a;return d}function jg(a,b,d){d.call(a,b)}function kg(a,b,d){var c=
+b.relatedTarget;c&&(c===a||lg.call(a,c))||d.call(a,b)}function $f(){this.$get=function(){return P(U,{hasClass:function(a,b){a.attr&&(a=a[0]);return Cb(a,b)},addClass:function(a,b){a.attr&&(a=a[0]);return Eb(a,b)},removeClass:function(a,b){a.attr&&(a=a[0]);return Db(a,b)}})}}function Pa(a,b){var d=a&&a.$$hashKey;if(d)return"function"===typeof d&&(d=a.$$hashKey()),d;d=typeof a;return d="function"===d||"object"===d&&null!==a?a.$$hashKey=d+":"+(b||pe)():d+":"+a}function md(){this._keys=[];this._values=
+[];this._lastKey=NaN;this._lastIndex=-1}function nd(a){a=Function.prototype.toString.call(a).replace(mg,"");return a.match(ng)||a.match(og)}function pg(a){return(a=nd(a))?"function("+(a[1]||"").replace(/[\s\r\n]+/," ")+")":"fn"}function hb(a,b){function d(a){return function(b,c){if(E(b))p(b,Vb(a));else return a(b,c)}}function c(a,b){Ia(a,"service");if(A(b)||I(b))b=q.instantiate(b);if(!b.$get)throw Ba("pget",a);return n[a+"Provider"]=b}function e(a,b){return function(){var c=z.invoke(b,this);if(w(c))throw Ba("undef",
+a);return c}}function f(a,b,d){return c(a,{$get:!1!==d?e(a,b):b})}function g(a){ib(w(a)||I(a),"modulesToLoad","not an array");var b=[],c;p(a,function(a){function d(a){var b,c;b=0;for(c=a.length;b<c;b++){var e=a[b],g=q.get(e[0]);g[e[1]].apply(g,e[2])}}if(!m.get(a)){m.set(a,!0);try{D(a)?(c=ec(a),z.modules[a]=c,b=b.concat(g(c.requires)).concat(c._runBlocks),d(c._invokeQueue),d(c._configBlocks)):A(a)?b.push(q.invoke(a)):I(a)?b.push(q.invoke(a)):ub(a,"module")}catch(e){throw I(a)&&(a=a[a.length-1]),e.message&&
+e.stack&&-1===e.stack.indexOf(e.message)&&(e=e.message+"\n"+e.stack),Ba("modulerr",a,e.stack||e.message||e);}}});return b}function k(a,c){function d(b,e){if(a.hasOwnProperty(b)){if(a[b]===h)throw Ba("cdep",b+" <- "+l.join(" <- "));return a[b]}try{return l.unshift(b),a[b]=h,a[b]=c(b,e),a[b]}catch(g){throw a[b]===h&&delete a[b],g;}finally{l.shift()}}function e(a,c,g){var f=[];a=hb.$$annotate(a,b,g);for(var h=0,k=a.length;h<k;h++){var l=a[h];if("string"!==typeof l)throw Ba("itkn",l);f.push(c&&c.hasOwnProperty(l)?
+c[l]:d(l,g))}return f}return{invoke:function(a,b,c,d){"string"===typeof c&&(d=c,c=null);c=e(a,c,d);I(a)&&(a=a[a.length-1]);d=a;if(Ca||"function"!==typeof d)d=!1;else{var g=d.$$ngIsClass;Na(g)||(g=d.$$ngIsClass=/^(?:class\b|constructor\()/.test(Function.prototype.toString.call(d)));d=g}return d?(c.unshift(null),new (Function.prototype.bind.apply(a,c))):a.apply(b,c)},instantiate:function(a,b,c){var d=I(a)?a[a.length-1]:a;a=e(a,b,c);a.unshift(null);return new (Function.prototype.bind.apply(d,a))},get:d,
+annotate:hb.$$annotate,has:function(b){return n.hasOwnProperty(b+"Provider")||a.hasOwnProperty(b)}}}b=!0===b;var h={},l=[],m=new Ib,n={$provide:{provider:d(c),factory:d(f),service:d(function(a,b){return f(a,["$injector",function(a){return a.instantiate(b)}])}),value:d(function(a,b){return f(a,ka(b),!1)}),constant:d(function(a,b){Ia(a,"constant");n[a]=b;G[a]=b}),decorator:function(a,b){var c=q.get(a+"Provider"),d=c.$get;c.$get=function(){var a=z.invoke(d,c);return z.invoke(b,null,{$delegate:a})}}}},
+q=n.$injector=k(n,function(a,b){$.isString(b)&&l.push(b);throw Ba("unpr",l.join(" <- "));}),G={},L=k(G,function(a,b){var c=q.get(a+"Provider",b);return z.invoke(c.$get,c,void 0,a)}),z=L;n.$injectorProvider={$get:ka(L)};z.modules=q.modules=S();var v=g(a),z=L.get("$injector");z.strictDi=b;p(v,function(a){a&&z.invoke(a)});return z}function pf(){var a=!0;this.disableAutoScrolling=function(){a=!1};this.$get=["$window","$location","$rootScope",function(b,d,c){function e(a){var b=null;Array.prototype.some.call(a,
+function(a){if("a"===za(a))return b=a,!0});return b}function f(a){if(a){a.scrollIntoView();var c;c=g.yOffset;A(c)?c=c():Xb(c)?(c=c[0],c="fixed"!==b.getComputedStyle(c).position?0:c.getBoundingClientRect().bottom):Y(c)||(c=0);c&&(a=a.getBoundingClientRect().top,b.scrollBy(0,a-c))}else b.scrollTo(0,0)}function g(a){a=D(a)?a:Y(a)?a.toString():d.hash();var b;a?(b=k.getElementById(a))?f(b):(b=e(k.getElementsByName(a)))?f(b):"top"===a&&f(null):f(null)}var k=b.document;a&&c.$watch(function(){return d.hash()},
+function(a,b){a===b&&""===a||hg(function(){c.$evalAsync(g)})});return g}]}function lb(a,b){if(!a&&!b)return"";if(!a)return b;if(!b)return a;I(a)&&(a=a.join(" "));I(b)&&(b=b.join(" "));return a+" "+b}function qg(a){D(a)&&(a=a.split(" "));var b=S();p(a,function(a){a.length&&(b[a]=!0)});return b}function Ka(a){return E(a)?a:{}}function rg(a,b,d,c){function e(a){try{a.apply(null,ya.call(arguments,1))}finally{if(L--,0===L)for(;z.length;)try{z.pop()()}catch(b){d.error(b)}}}function f(){y=null;k()}function g(){v=
+J();v=w(v)?null:v;sa(v,K)&&(v=K);s=K=v}function k(){var a=s;g();if(Ta!==h.url()||a!==v)Ta=h.url(),s=v,p(H,function(a){a(h.url(),v)})}var h=this,l=a.location,m=a.history,n=a.setTimeout,q=a.clearTimeout,G={};h.isMock=!1;var L=0,z=[];h.$$completeOutstandingRequest=e;h.$$incOutstandingRequestCount=function(){L++};h.notifyWhenNoOutstandingRequests=function(a){0===L?a():z.push(a)};var v,s,Ta=l.href,ma=b.find("base"),y=null,J=c.history?function(){try{return m.state}catch(a){}}:C;g();h.url=function(b,d,e){w(e)&&
+(e=null);l!==a.location&&(l=a.location);m!==a.history&&(m=a.history);if(b){var f=s===e;if(Ta===b&&(!c.history||f))return h;var k=Ta&&La(Ta)===La(b);Ta=b;s=e;!c.history||k&&f?(k||(y=b),d?l.replace(b):k?(d=l,e=b.indexOf("#"),e=-1===e?"":b.substr(e),d.hash=e):l.href=b,l.href!==b&&(y=b)):(m[d?"replaceState":"pushState"](e,"",b),g());y&&(y=b);return h}return y||l.href.replace(/%27/g,"'")};h.state=function(){return v};var H=[],ta=!1,K=null;h.onUrlChange=function(b){if(!ta){if(c.history)B(a).on("popstate",
+f);B(a).on("hashchange",f);ta=!0}H.push(b);return b};h.$$applicationDestroyed=function(){B(a).off("hashchange popstate",f)};h.$$checkUrlChange=k;h.baseHref=function(){var a=ma.attr("href");return a?a.replace(/^(https?:)?\/\/[^/]*/,""):""};h.defer=function(a,b){var c;L++;c=n(function(){delete G[c];e(a)},b||0);G[c]=!0;return c};h.defer.cancel=function(a){return G[a]?(delete G[a],q(a),e(C),!0):!1}}function wf(){this.$get=["$window","$log","$sniffer","$document",function(a,b,d,c){return new rg(a,c,b,
+d)}]}function xf(){this.$get=function(){function a(a,c){function e(a){a!==n&&(q?q===a&&(q=a.n):q=a,f(a.n,a.p),f(a,n),n=a,n.n=null)}function f(a,b){a!==b&&(a&&(a.p=b),b&&(b.n=a))}if(a in b)throw M("$cacheFactory")("iid",a);var g=0,k=P({},c,{id:a}),h=S(),l=c&&c.capacity||Number.MAX_VALUE,m=S(),n=null,q=null;return b[a]={put:function(a,b){if(!w(b)){if(l<Number.MAX_VALUE){var c=m[a]||(m[a]={key:a});e(c)}a in h||g++;h[a]=b;g>l&&this.remove(q.key);return b}},get:function(a){if(l<Number.MAX_VALUE){var b=
+m[a];if(!b)return;e(b)}return h[a]},remove:function(a){if(l<Number.MAX_VALUE){var b=m[a];if(!b)return;b===n&&(n=b.p);b===q&&(q=b.n);f(b.n,b.p);delete m[a]}a in h&&(delete h[a],g--)},removeAll:function(){h=S();g=0;m=S();n=q=null},destroy:function(){m=k=h=null;delete b[a]},info:function(){return P({},k,{size:g})}}}var b={};a.info=function(){var a={};p(b,function(b,e){a[e]=b.info()});return a};a.get=function(a){return b[a]};return a}}function Uf(){this.$get=["$cacheFactory",function(a){return a("templates")}]}
+function Yc(a,b){function d(a,b,c){var d=/^\s*([@&<]|=(\*?))(\??)\s*([\w$]*)\s*$/,e=S();p(a,function(a,g){if(a in n)e[g]=n[a];else{var f=a.match(d);if(!f)throw ba("iscp",b,g,a,c?"controller bindings definition":"isolate scope definition");e[g]={mode:f[1][0],collection:"*"===f[2],optional:"?"===f[3],attrName:f[4]||g};f[4]&&(n[a]=e[g])}});return e}function c(a){var b=a.charAt(0);if(!b||b!==N(b))throw ba("baddir",a);if(a!==a.trim())throw ba("baddir",a);}function e(a){var b=a.require||a.controller&&a.name;
+!I(b)&&E(b)&&p(b,function(a,c){var d=a.match(l);a.substring(d[0].length)||(b[c]=d[0]+c)});return b}var f={},g=/^\s*directive:\s*([\w-]+)\s+(.*)$/,k=/(([\w-]+)(?::([^;]+))?;?)/,h=te("ngSrc,ngSrcset,src,srcset"),l=/^(?:(\^\^?)?(\?)?(\^\^?)?)?/,m=/^(on[a-z]+|formaction)$/,n=S();this.directive=function ma(b,d){ib(b,"name");Ia(b,"directive");D(b)?(c(b),ib(d,"directiveFactory"),f.hasOwnProperty(b)||(f[b]=[],a.factory(b+"Directive",["$injector","$exceptionHandler",function(a,c){var d=[];p(f[b],function(g,
+f){try{var h=a.invoke(g);A(h)?h={compile:ka(h)}:!h.compile&&h.link&&(h.compile=ka(h.link));h.priority=h.priority||0;h.index=f;h.name=h.name||b;h.require=e(h);var k=h,l=h.restrict;if(l&&(!D(l)||!/[EACM]/.test(l)))throw ba("badrestrict",l,b);k.restrict=l||"EA";h.$$moduleName=g.$$moduleName;d.push(h)}catch(m){c(m)}});return d}])),f[b].push(d)):p(b,Vb(ma));return this};this.component=function y(a,b){function c(a){function e(b){return A(b)||I(b)?function(c,d){return a.invoke(b,this,{$element:c,$attrs:d})}:
+b}var g=b.template||b.templateUrl?b.template:"",f={controller:d,controllerAs:sg(b.controller)||b.controllerAs||"$ctrl",template:e(g),templateUrl:e(b.templateUrl),transclude:b.transclude,scope:{},bindToController:b.bindings||{},restrict:"E",require:b.require};p(b,function(a,b){"$"===b.charAt(0)&&(f[b]=a)});return f}if(!D(a))return p(a,Vb(Ra(this,y))),this;var d=b.controller||function(){};p(b,function(a,b){"$"===b.charAt(0)&&(c[b]=a,A(d)&&(d[b]=a))});c.$inject=["$injector"];return this.directive(a,
+c)};this.aHrefSanitizationWhitelist=function(a){return t(a)?(b.aHrefSanitizationWhitelist(a),this):b.aHrefSanitizationWhitelist()};this.imgSrcSanitizationWhitelist=function(a){return t(a)?(b.imgSrcSanitizationWhitelist(a),this):b.imgSrcSanitizationWhitelist()};var q=!0;this.debugInfoEnabled=function(a){return t(a)?(q=a,this):q};var G=!1;this.preAssignBindingsEnabled=function(a){return t(a)?(G=a,this):G};var L=!1;this.strictComponentBindingsEnabled=function(a){return t(a)?(L=a,this):L};var z=10;this.onChangesTtl=
+function(a){return arguments.length?(z=a,this):z};var v=!0;this.commentDirectivesEnabled=function(a){return arguments.length?(v=a,this):v};var s=!0;this.cssClassDirectivesEnabled=function(a){return arguments.length?(s=a,this):s};this.$get=["$injector","$interpolate","$exceptionHandler","$templateRequest","$parse","$controller","$rootScope","$sce","$animate","$$sanitizeUri",function(a,b,c,e,n,F,R,x,W,r){function O(){try{if(!--Fa)throw ga=void 0,ba("infchng",z);R.$apply(function(){for(var a=[],b=0,
+c=ga.length;b<c;++b)try{ga[b]()}catch(d){a.push(d)}ga=void 0;if(a.length)throw a;})}finally{Fa++}}function mc(a,b){if(b){var c=Object.keys(b),d,e,g;d=0;for(e=c.length;d<e;d++)g=c[d],this[g]=b[g]}else this.$attr={};this.$$element=a}function Ua(a,b,c){Ba.innerHTML="<span "+b+">";b=Ba.firstChild.attributes;var d=b[0];b.removeNamedItem(d.name);d.value=c;a.attributes.setNamedItem(d)}function na(a,b){try{a.addClass(b)}catch(c){}}function ca(a,b,c,d,e){a instanceof B||(a=B(a));var g=Va(a,b,a,c,d,e);ca.$$addScopeClass(a);
+var f=null;return function(b,c,d){if(!a)throw ba("multilink");ib(b,"scope");e&&e.needsNewScope&&(b=b.$parent.$new());d=d||{};var h=d.parentBoundTranscludeFn,k=d.transcludeControllers;d=d.futureParentElement;h&&h.$$boundTransclude&&(h=h.$$boundTransclude);f||(f=(d=d&&d[0])?"foreignobject"!==za(d)&&ha.call(d).match(/SVG/)?"svg":"html":"html");d="html"!==f?B(ja(f,B("<div>").append(a).html())):c?Sa.clone.call(a):a;if(k)for(var l in k)d.data("$"+l+"Controller",k[l].instance);ca.$$addScopeInfo(d,b);c&&
+c(d,b);g&&g(b,d,d,h);c||(a=g=null);return d}}function Va(a,b,c,d,e,g){function f(a,c,d,e){var g,k,l,m,q,n,H;if(s)for(H=Array(c.length),m=0;m<h.length;m+=3)g=h[m],H[g]=c[g];else H=c;m=0;for(q=h.length;m<q;)k=H[h[m++]],c=h[m++],g=h[m++],c?(c.scope?(l=a.$new(),ca.$$addScopeInfo(B(k),l)):l=a,n=c.transcludeOnThisElement?Ma(a,c.transclude,e):!c.templateOnThisElement&&e?e:!e&&b?Ma(a,b):null,c(g,l,k,d,n)):g&&g(a,k.childNodes,void 0,e)}for(var h=[],k=I(a)||a instanceof B,l,m,q,n,s,H=0;H<a.length;H++){l=new mc;
+11===Ca&&Da(a,H,k);m=M(a[H],[],l,0===H?d:void 0,e);(g=m.length?Y(m,a[H],l,b,c,null,[],[],g):null)&&g.scope&&ca.$$addScopeClass(l.$$element);l=g&&g.terminal||!(q=a[H].childNodes)||!q.length?null:Va(q,g?(g.transcludeOnThisElement||!g.templateOnThisElement)&&g.transclude:b);if(g||l)h.push(H,g,l),n=!0,s=s||g;g=null}return n?f:null}function Da(a,b,c){var d=a[b],e=d.parentNode,g;if(d.nodeType===Oa)for(;;){g=e?d.nextSibling:a[b+1];if(!g||g.nodeType!==Oa)break;d.nodeValue+=g.nodeValue;g.parentNode&&g.parentNode.removeChild(g);
+c&&g===a[b+1]&&a.splice(b+1,1)}}function Ma(a,b,c){function d(e,g,f,h,k){e||(e=a.$new(!1,k),e.$$transcluded=!0);return b(e,g,{parentBoundTranscludeFn:c,transcludeControllers:f,futureParentElement:h})}var e=d.$$slots=S(),g;for(g in b.$$slots)e[g]=b.$$slots[g]?Ma(a,b.$$slots[g],c):null;return d}function M(a,b,c,d,e){var g=c.$attr,f;switch(a.nodeType){case 1:f=za(a);T(b,Ea(f),"E",d,e);for(var h,l,m,q,n=a.attributes,s=0,H=n&&n.length;s<H;s++){var J=!1,G=!1;h=n[s];l=h.name;m=h.value;h=Ea(l);(q=Pa.test(h))&&
+(l=l.replace(od,"").substr(8).replace(/_(.)/g,function(a,b){return b.toUpperCase()}));(h=h.match(Qa))&&$(h[1])&&(J=l,G=l.substr(0,l.length-5)+"end",l=l.substr(0,l.length-6));h=Ea(l.toLowerCase());g[h]=l;if(q||!c.hasOwnProperty(h))c[h]=m,kd(a,h)&&(c[h]=!0);xa(a,b,m,h,q);T(b,h,"A",d,e,J,G)}"input"===f&&"hidden"===a.getAttribute("type")&&a.setAttribute("autocomplete","off");if(!La)break;g=a.className;E(g)&&(g=g.animVal);if(D(g)&&""!==g)for(;a=k.exec(g);)h=Ea(a[2]),T(b,h,"C",d,e)&&(c[h]=Q(a[3])),g=g.substr(a.index+
+a[0].length);break;case Oa:oa(b,a.nodeValue);break;case 8:if(!Ka)break;nc(a,b,c,d,e)}b.sort(ka);return b}function nc(a,b,c,d,e){try{var f=g.exec(a.nodeValue);if(f){var h=Ea(f[1]);T(b,h,"M",d,e)&&(c[h]=Q(f[2]))}}catch(k){}}function pd(a,b,c){var d=[],e=0;if(b&&a.hasAttribute&&a.hasAttribute(b)){do{if(!a)throw ba("uterdir",b,c);1===a.nodeType&&(a.hasAttribute(b)&&e++,a.hasAttribute(c)&&e--);d.push(a);a=a.nextSibling}while(0<e)}else d.push(a);return B(d)}function U(a,b,c){return function(d,e,g,f,h){e=
+pd(e[0],b,c);return a(d,e,g,f,h)}}function V(a,b,c,d,e,g){var f;return a?ca(b,c,d,e,g):function(){f||(f=ca(b,c,d,e,g),b=c=g=null);return f.apply(this,arguments)}}function Y(a,b,d,e,g,f,h,k,l){function m(a,b,c,d){if(a){c&&(a=U(a,c,d));a.require=x.require;a.directiveName=W;if(K===x||x.$$isolateScope)a=ua(a,{isolateScope:!0});h.push(a)}if(b){c&&(b=U(b,c,d));b.require=x.require;b.directiveName=W;if(K===x||x.$$isolateScope)b=ua(b,{isolateScope:!0});k.push(b)}}function q(a,e,g,f,l){function m(a,b,c,d){var e;
+cb(a)||(d=c,c=b,b=a,a=void 0);ta&&(e=L);c||(c=ta?fa.parent():fa);if(d){var g=l.$$slots[d];if(g)return g(a,b,e,c,O);if(w(g))throw ba("noslot",d,Aa(fa));}else return l(a,b,e,c,O)}var n,x,F,y,R,L,z,fa;b===g?(f=d,fa=d.$$element):(fa=B(g),f=new mc(fa,d));R=e;K?y=e.$new(!0):s&&(R=e.$parent);l&&(z=m,z.$$boundTransclude=l,z.isSlotFilled=function(a){return!!l.$$slots[a]});J&&(L=da(fa,f,z,J,y,e,K));K&&(ca.$$addScopeInfo(fa,y,!0,!(v&&(v===K||v===K.$$originalDirective))),ca.$$addScopeClass(fa,!0),y.$$isolateBindings=
+K.$$isolateBindings,x=qa(e,f,y,y.$$isolateBindings,K),x.removeWatches&&y.$on("$destroy",x.removeWatches));for(n in L){x=J[n];F=L[n];var W=x.$$bindings.bindToController;if(G){F.bindingInfo=W?qa(R,f,F.instance,W,x):{};var r=F();r!==F.instance&&(F.instance=r,fa.data("$"+x.name+"Controller",r),F.bindingInfo.removeWatches&&F.bindingInfo.removeWatches(),F.bindingInfo=qa(R,f,F.instance,W,x))}else F.instance=F(),fa.data("$"+x.name+"Controller",F.instance),F.bindingInfo=qa(R,f,F.instance,W,x)}p(J,function(a,
+b){var c=a.require;a.bindToController&&!I(c)&&E(c)&&P(L[b].instance,X(b,c,fa,L))});p(L,function(a){var b=a.instance;if(A(b.$onChanges))try{b.$onChanges(a.bindingInfo.initialChanges)}catch(d){c(d)}if(A(b.$onInit))try{b.$onInit()}catch(e){c(e)}A(b.$doCheck)&&(R.$watch(function(){b.$doCheck()}),b.$doCheck());A(b.$onDestroy)&&R.$on("$destroy",function(){b.$onDestroy()})});n=0;for(x=h.length;n<x;n++)F=h[n],wa(F,F.isolateScope?y:e,fa,f,F.require&&X(F.directiveName,F.require,fa,L),z);var O=e;K&&(K.template||
+null===K.templateUrl)&&(O=y);a&&a(O,g.childNodes,void 0,l);for(n=k.length-1;0<=n;n--)F=k[n],wa(F,F.isolateScope?y:e,fa,f,F.require&&X(F.directiveName,F.require,fa,L),z);p(L,function(a){a=a.instance;A(a.$postLink)&&a.$postLink()})}l=l||{};for(var n=-Number.MAX_VALUE,s=l.newScopeDirective,J=l.controllerDirectives,K=l.newIsolateScopeDirective,v=l.templateDirective,y=l.nonTlbTranscludeDirective,R=!1,L=!1,ta=l.hasElementTranscludeDirective,F=d.$$element=B(b),x,W,z,r=e,O,t=!1,Jb=!1,u,Da=0,C=a.length;Da<
+C;Da++){x=a[Da];var Ua=x.$$start,D=x.$$end;Ua&&(F=pd(b,Ua,D));z=void 0;if(n>x.priority)break;if(u=x.scope)x.templateUrl||(E(u)?(aa("new/isolated scope",K||s,x,F),K=x):aa("new/isolated scope",K,x,F)),s=s||x;W=x.name;if(!t&&(x.replace&&(x.templateUrl||x.template)||x.transclude&&!x.$$tlb)){for(u=Da+1;t=a[u++];)if(t.transclude&&!t.$$tlb||t.replace&&(t.templateUrl||t.template)){Jb=!0;break}t=!0}!x.templateUrl&&x.controller&&(J=J||S(),aa("'"+W+"' controller",J[W],x,F),J[W]=x);if(u=x.transclude)if(R=!0,
+x.$$tlb||(aa("transclusion",y,x,F),y=x),"element"===u)ta=!0,n=x.priority,z=F,F=d.$$element=B(ca.$$createComment(W,d[W])),b=F[0],la(g,ya.call(z,0),b),z[0].$$parentNode=z[0].parentNode,r=V(Jb,z,e,n,f&&f.name,{nonTlbTranscludeDirective:y});else{var na=S();if(E(u)){z=[];var Va=S(),Ma=S();p(u,function(a,b){var c="?"===a.charAt(0);a=c?a.substring(1):a;Va[a]=b;na[b]=null;Ma[b]=c});p(F.contents(),function(a){var b=Va[Ea(za(a))];b?(Ma[b]=!0,na[b]=na[b]||[],na[b].push(a)):z.push(a)});p(Ma,function(a,b){if(!a)throw ba("reqslot",
+b);});for(var N in na)na[N]&&(na[N]=V(Jb,na[N],e))}else z=B(jc(b)).contents();F.empty();r=V(Jb,z,e,void 0,void 0,{needsNewScope:x.$$isolateScope||x.$$newScope});r.$$slots=na}if(x.template)if(L=!0,aa("template",v,x,F),v=x,u=A(x.template)?x.template(F,d):x.template,u=Ia(u),x.replace){f=x;z=gc.test(u)?qd(ja(x.templateNamespace,Q(u))):[];b=z[0];if(1!==z.length||1!==b.nodeType)throw ba("tplrt",W,"");la(g,F,b);C={$attr:{}};u=M(b,[],C);var nc=a.splice(Da+1,a.length-(Da+1));(K||s)&&Z(u,K,s);a=a.concat(u).concat(nc);
+ea(d,C);C=a.length}else F.html(u);if(x.templateUrl)L=!0,aa("template",v,x,F),v=x,x.replace&&(f=x),q=ia(a.splice(Da,a.length-Da),F,d,g,R&&r,h,k,{controllerDirectives:J,newScopeDirective:s!==x&&s,newIsolateScopeDirective:K,templateDirective:v,nonTlbTranscludeDirective:y}),C=a.length;else if(x.compile)try{O=x.compile(F,d,r);var T=x.$$originalDirective||x;A(O)?m(null,Ra(T,O),Ua,D):O&&m(Ra(T,O.pre),Ra(T,O.post),Ua,D)}catch($){c($,Aa(F))}x.terminal&&(q.terminal=!0,n=Math.max(n,x.priority))}q.scope=s&&!0===
+s.scope;q.transcludeOnThisElement=R;q.templateOnThisElement=L;q.transclude=r;l.hasElementTranscludeDirective=ta;return q}function X(a,b,c,d){var e;if(D(b)){var g=b.match(l);b=b.substring(g[0].length);var f=g[1]||g[3],g="?"===g[2];"^^"===f?c=c.parent():e=(e=d&&d[b])&&e.instance;if(!e){var h="$"+b+"Controller";e=f?c.inheritedData(h):c.data(h)}if(!e&&!g)throw ba("ctreq",b,a);}else if(I(b))for(e=[],f=0,g=b.length;f<g;f++)e[f]=X(a,b[f],c,d);else E(b)&&(e={},p(b,function(b,g){e[g]=X(a,b,c,d)}));return e||
+null}function da(a,b,c,d,e,g,f){var h=S(),k;for(k in d){var l=d[k],m={$scope:l===f||l.$$isolateScope?e:g,$element:a,$attrs:b,$transclude:c},n=l.controller;"@"===n&&(n=b[l.name]);m=F(n,m,!0,l.controllerAs);h[l.name]=m;a.data("$"+l.name+"Controller",m.instance)}return h}function Z(a,b,c){for(var d=0,e=a.length;d<e;d++)a[d]=Yb(a[d],{$$isolateScope:b,$$newScope:c})}function T(b,c,e,g,h,k,l){if(c===h)return null;var m=null;if(f.hasOwnProperty(c)){h=a.get(c+"Directive");for(var n=0,q=h.length;n<q;n++)if(c=
+h[n],(w(g)||g>c.priority)&&-1!==c.restrict.indexOf(e)){k&&(c=Yb(c,{$$start:k,$$end:l}));if(!c.$$bindings){var s=m=c,H=c.name,J={isolateScope:null,bindToController:null};E(s.scope)&&(!0===s.bindToController?(J.bindToController=d(s.scope,H,!0),J.isolateScope={}):J.isolateScope=d(s.scope,H,!1));E(s.bindToController)&&(J.bindToController=d(s.bindToController,H,!0));if(J.bindToController&&!s.controller)throw ba("noctrl",H);m=m.$$bindings=J;E(m.isolateScope)&&(c.$$isolateBindings=m.isolateScope)}b.push(c);
+m=c}}return m}function $(b){if(f.hasOwnProperty(b))for(var c=a.get(b+"Directive"),d=0,e=c.length;d<e;d++)if(b=c[d],b.multiElement)return!0;return!1}function ea(a,b){var c=b.$attr,d=a.$attr;p(a,function(d,e){"$"!==e.charAt(0)&&(b[e]&&b[e]!==d&&(d=d.length?d+(("style"===e?";":" ")+b[e]):b[e]),a.$set(e,d,!0,c[e]))});p(b,function(b,e){a.hasOwnProperty(e)||"$"===e.charAt(0)||(a[e]=b,"class"!==e&&"style"!==e&&(d[e]=c[e]))})}function ia(a,b,d,g,f,h,k,l){var m=[],n,q,s=b[0],J=a.shift(),x=Yb(J,{templateUrl:null,
+transclude:null,replace:null,$$originalDirective:J}),G=A(J.templateUrl)?J.templateUrl(b,d):J.templateUrl,F=J.templateNamespace;b.empty();e(G).then(function(c){var e,H;c=Ia(c);if(J.replace){c=gc.test(c)?qd(ja(F,Q(c))):[];e=c[0];if(1!==c.length||1!==e.nodeType)throw ba("tplrt",J.name,G);c={$attr:{}};la(g,b,e);var K=M(e,[],c);E(J.scope)&&Z(K,!0);a=K.concat(a);ea(d,c)}else e=s,b.html(c);a.unshift(x);n=Y(a,e,d,f,b,J,h,k,l);p(g,function(a,c){a===e&&(g[c]=b[0])});for(q=Va(b[0].childNodes,f);m.length;){c=
+m.shift();H=m.shift();var v=m.shift(),y=m.shift(),K=b[0];if(!c.$$destroyed){if(H!==s){var L=H.className;l.hasElementTranscludeDirective&&J.replace||(K=jc(e));la(v,B(H),K);na(B(K),L)}H=n.transcludeOnThisElement?Ma(c,n.transclude,y):y;n(q,c,K,g,H)}}m=null}).catch(function(a){$b(a)&&c(a)});return function(a,b,c,d,e){a=e;b.$$destroyed||(m?m.push(b,c,d,a):(n.transcludeOnThisElement&&(a=Ma(b,n.transclude,e)),n(q,b,c,d,a)))}}function ka(a,b){var c=b.priority-a.priority;return 0!==c?c:a.name!==b.name?a.name<
+b.name?-1:1:a.index-b.index}function aa(a,b,c,d){function e(a){return a?" (module: "+a+")":""}if(b)throw ba("multidir",b.name,e(b.$$moduleName),c.name,e(c.$$moduleName),a,Aa(d));}function oa(a,c){var d=b(c,!0);d&&a.push({priority:0,compile:function(a){a=a.parent();var b=!!a.length;b&&ca.$$addBindingClass(a);return function(a,c){var e=c.parent();b||ca.$$addBindingClass(e);ca.$$addBindingInfo(e,d.expressions);a.$watch(d,function(a){c[0].nodeValue=a})}}})}function ja(a,b){a=N(a||"html");switch(a){case "svg":case "math":var c=
+u.document.createElement("div");c.innerHTML="<"+a+">"+b+"</"+a+">";return c.childNodes[0].childNodes;default:return b}}function va(a,b){if("srcdoc"===b)return x.HTML;var c=za(a);if("src"===b||"ngSrc"===b){if(-1===["img","video","audio","source","track"].indexOf(c))return x.RESOURCE_URL}else if("xlinkHref"===b||"form"===c&&"action"===b||"link"===c&&"href"===b)return x.RESOURCE_URL}function xa(a,c,d,e,g){var f=va(a,e),k=h[e]||g,l=b(d,!g,f,k);if(l){if("multiple"===e&&"select"===za(a))throw ba("selmulti",
+Aa(a));if(m.test(e))throw ba("nodomevents");c.push({priority:100,compile:function(){return{pre:function(a,c,g){c=g.$$observers||(g.$$observers=S());var h=g[e];h!==d&&(l=h&&b(h,!0,f,k),d=h);l&&(g[e]=l(a),(c[e]||(c[e]=[])).$$inter=!0,(g.$$observers&&g.$$observers[e].$$scope||a).$watch(l,function(a,b){"class"===e&&a!==b?g.$updateClass(a,b):g.$set(e,a)}))}}}})}}function la(a,b,c){var d=b[0],e=b.length,g=d.parentNode,f,h;if(a)for(f=0,h=a.length;f<h;f++)if(a[f]===d){a[f++]=c;h=f+e-1;for(var k=a.length;f<
+k;f++,h++)h<k?a[f]=a[h]:delete a[f];a.length-=e-1;a.context===d&&(a.context=c);break}g&&g.replaceChild(c,d);a=u.document.createDocumentFragment();for(f=0;f<e;f++)a.appendChild(b[f]);B.hasData(d)&&(B.data(c,B.data(d)),B(d).off("$destroy"));B.cleanData(a.querySelectorAll("*"));for(f=1;f<e;f++)delete b[f];b[0]=c;b.length=1}function ua(a,b){return P(function(){return a.apply(null,arguments)},a,b)}function wa(a,b,d,e,g,f){try{a(b,d,e,g,f)}catch(h){c(h,Aa(d))}}function pa(a,b){if(L)throw ba("missingattr",
+a,b);}function qa(a,c,d,e,g){function f(b,c,e){A(d.$onChanges)&&!ac(c,e)&&(ga||(a.$$postDigest(O),ga=[]),m||(m={},ga.push(h)),m[b]&&(e=m[b].previousValue),m[b]=new Kb(e,c))}function h(){d.$onChanges(m);m=void 0}var k=[],l={},m;p(e,function(e,h){var m=e.attrName,q=e.optional,s,H,x,G;switch(e.mode){case "@":q||ra.call(c,m)||(pa(m,g.name),d[h]=c[m]=void 0);q=c.$observe(m,function(a){if(D(a)||Na(a))f(h,a,d[h]),d[h]=a});c.$$observers[m].$$scope=a;s=c[m];D(s)?d[h]=b(s)(a):Na(s)&&(d[h]=s);l[h]=new Kb(oc,
+d[h]);k.push(q);break;case "=":if(!ra.call(c,m)){if(q)break;pa(m,g.name);c[m]=void 0}if(q&&!c[m])break;H=n(c[m]);G=H.literal?sa:ac;x=H.assign||function(){s=d[h]=H(a);throw ba("nonassign",c[m],m,g.name);};s=d[h]=H(a);q=function(b){G(b,d[h])||(G(b,s)?x(a,b=d[h]):d[h]=b);return s=b};q.$stateful=!0;q=e.collection?a.$watchCollection(c[m],q):a.$watch(n(c[m],q),null,H.literal);k.push(q);break;case "<":if(!ra.call(c,m)){if(q)break;pa(m,g.name);c[m]=void 0}if(q&&!c[m])break;H=n(c[m]);var F=H.literal,v=d[h]=
+H(a);l[h]=new Kb(oc,d[h]);q=a.$watch(H,function(a,b){if(b===a){if(b===v||F&&sa(b,v))return;b=v}f(h,a,b);d[h]=a},F);k.push(q);break;case "&":q||ra.call(c,m)||pa(m,g.name);H=c.hasOwnProperty(m)?n(c[m]):C;if(H===C&&q)break;d[h]=function(b){return H(a,b)}}});return{initialChanges:l,removeWatches:k.length&&function(){for(var a=0,b=k.length;a<b;++a)k[a]()}}}var Ja=/^\w/,Ba=u.document.createElement("div"),Ka=v,La=s,Fa=z,ga;mc.prototype={$normalize:Ea,$addClass:function(a){a&&0<a.length&&W.addClass(this.$$element,
+a)},$removeClass:function(a){a&&0<a.length&&W.removeClass(this.$$element,a)},$updateClass:function(a,b){var c=rd(a,b);c&&c.length&&W.addClass(this.$$element,c);(c=rd(b,a))&&c.length&&W.removeClass(this.$$element,c)},$set:function(a,b,d,e){var g=kd(this.$$element[0],a),f=sd[a],h=a;g?(this.$$element.prop(a,b),e=g):f&&(this[f]=b,h=f);this[a]=b;e?this.$attr[a]=e:(e=this.$attr[a])||(this.$attr[a]=e=Vc(a,"-"));g=za(this.$$element);if("a"===g&&("href"===a||"xlinkHref"===a)||"img"===g&&"src"===a)this[a]=
+b=r(b,"src"===a);else if("img"===g&&"srcset"===a&&t(b)){for(var g="",f=Q(b),k=/(\s+\d+x\s*,|\s+\d+w\s*,|\s+,|,\s+)/,k=/\s/.test(f)?k:/(,)/,f=f.split(k),k=Math.floor(f.length/2),l=0;l<k;l++)var m=2*l,g=g+r(Q(f[m]),!0),g=g+(" "+Q(f[m+1]));f=Q(f[2*l]).split(/\s/);g+=r(Q(f[0]),!0);2===f.length&&(g+=" "+Q(f[1]));this[a]=b=g}!1!==d&&(null===b||w(b)?this.$$element.removeAttr(e):Ja.test(e)?this.$$element.attr(e,b):Ua(this.$$element[0],e,b));(a=this.$$observers)&&p(a[h],function(a){try{a(b)}catch(d){c(d)}})},
+$observe:function(a,b){var c=this,d=c.$$observers||(c.$$observers=S()),e=d[a]||(d[a]=[]);e.push(b);R.$evalAsync(function(){e.$$inter||!c.hasOwnProperty(a)||w(c[a])||b(c[a])});return function(){db(e,b)}}};var Ga=b.startSymbol(),Ha=b.endSymbol(),Ia="{{"===Ga&&"}}"===Ha?bb:function(a){return a.replace(/\{\{/g,Ga).replace(/}}/g,Ha)},Pa=/^ngAttr[A-Z]/,Qa=/^(.+)Start$/;ca.$$addBindingInfo=q?function(a,b){var c=a.data("$binding")||[];I(b)?c=c.concat(b):c.push(b);a.data("$binding",c)}:C;ca.$$addBindingClass=
+q?function(a){na(a,"ng-binding")}:C;ca.$$addScopeInfo=q?function(a,b,c,d){a.data(c?d?"$isolateScopeNoTemplate":"$isolateScope":"$scope",b)}:C;ca.$$addScopeClass=q?function(a,b){na(a,b?"ng-isolate-scope":"ng-scope")}:C;ca.$$createComment=function(a,b){var c="";q&&(c=" "+(a||"")+": ",b&&(c+=b+" "));return u.document.createComment(c)};return ca}]}function Kb(a,b){this.previousValue=a;this.currentValue=b}function Ea(a){return a.replace(od,"").replace(tg,jb)}function rd(a,b){var d="",c=a.split(/\s+/),
+e=b.split(/\s+/),f=0;a:for(;f<c.length;f++){for(var g=c[f],k=0;k<e.length;k++)if(g===e[k])continue a;d+=(0<d.length?" ":"")+g}return d}function qd(a){a=B(a);var b=a.length;if(1>=b)return a;for(;b--;){var d=a[b];(8===d.nodeType||d.nodeType===Oa&&""===d.nodeValue.trim())&&ug.call(a,b,1)}return a}function sg(a,b){if(b&&D(b))return b;if(D(a)){var d=td.exec(a);if(d)return d[3]}}function yf(){var a={},b=!1;this.has=function(b){return a.hasOwnProperty(b)};this.register=function(b,c){Ia(b,"controller");E(b)?
+P(a,b):a[b]=c};this.allowGlobals=function(){b=!0};this.$get=["$injector","$window",function(d,c){function e(a,b,c,d){if(!a||!E(a.$scope))throw M("$controller")("noscp",d,b);a.$scope[b]=c}return function(f,g,k,h){var l,m,n;k=!0===k;h&&D(h)&&(n=h);if(D(f)){h=f.match(td);if(!h)throw ud("ctrlfmt",f);m=h[1];n=n||h[3];f=a.hasOwnProperty(m)?a[m]:Xc(g.$scope,m,!0)||(b?Xc(c,m,!0):void 0);if(!f)throw ud("ctrlreg",m);ub(f,m,!0)}if(k)return k=(I(f)?f[f.length-1]:f).prototype,l=Object.create(k||null),n&&e(g,n,
+l,m||f.name),P(function(){var a=d.invoke(f,l,g,m);a!==l&&(E(a)||A(a))&&(l=a,n&&e(g,n,l,m||f.name));return l},{instance:l,identifier:n});l=d.instantiate(f,g,m);n&&e(g,n,l,m||f.name);return l}}]}function zf(){this.$get=["$window",function(a){return B(a.document)}]}function Af(){this.$get=["$document","$rootScope",function(a,b){function d(){e=c.hidden}var c=a[0],e=c&&c.hidden;a.on("visibilitychange",d);b.$on("$destroy",function(){a.off("visibilitychange",d)});return function(){return e}}]}function Bf(){this.$get=
+["$log",function(a){return function(b,d){a.error.apply(a,arguments)}}]}function pc(a){return E(a)?ea(a)?a.toISOString():fb(a):a}function Gf(){this.$get=function(){return function(a){if(!a)return"";var b=[];Nc(a,function(a,c){null===a||w(a)||A(a)||(I(a)?p(a,function(a){b.push(ia(c)+"="+ia(pc(a)))}):b.push(ia(c)+"="+ia(pc(a))))});return b.join("&")}}}function Hf(){this.$get=function(){return function(a){function b(a,e,f){null===a||w(a)||(I(a)?p(a,function(a,c){b(a,e+"["+(E(a)?c:"")+"]")}):E(a)&&!ea(a)?
+Nc(a,function(a,c){b(a,e+(f?"":"[")+c+(f?"":"]"))}):d.push(ia(e)+"="+ia(pc(a))))}if(!a)return"";var d=[];b(a,"",!0);return d.join("&")}}}function qc(a,b){if(D(a)){var d=a.replace(vg,"").trim();if(d){var c=b("Content-Type"),c=c&&0===c.indexOf(vd),e;(e=c)||(e=(e=d.match(wg))&&xg[e[0]].test(d));if(e)try{a=Qc(d)}catch(f){if(!c)return a;throw rc("baddata",a,f);}}}return a}function wd(a){var b=S(),d;D(a)?p(a.split("\n"),function(a){d=a.indexOf(":");var e=N(Q(a.substr(0,d)));a=Q(a.substr(d+1));e&&(b[e]=
+b[e]?b[e]+", "+a:a)}):E(a)&&p(a,function(a,d){var f=N(d),g=Q(a);f&&(b[f]=b[f]?b[f]+", "+g:g)});return b}function xd(a){var b;return function(d){b||(b=wd(a));return d?(d=b[N(d)],void 0===d&&(d=null),d):b}}function yd(a,b,d,c){if(A(c))return c(a,b,d);p(c,function(c){a=c(a,b,d)});return a}function Ff(){var a=this.defaults={transformResponse:[qc],transformRequest:[function(a){return E(a)&&"[object File]"!==ha.call(a)&&"[object Blob]"!==ha.call(a)&&"[object FormData]"!==ha.call(a)?fb(a):a}],headers:{common:{Accept:"application/json, text/plain, */*"},
+post:ja(sc),put:ja(sc),patch:ja(sc)},xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",paramSerializer:"$httpParamSerializer",jsonpCallbackParam:"callback"},b=!1;this.useApplyAsync=function(a){return t(a)?(b=!!a,this):b};var d=this.interceptors=[];this.$get=["$browser","$httpBackend","$$cookieReader","$cacheFactory","$rootScope","$q","$injector","$sce",function(c,e,f,g,k,h,l,m){function n(b){function d(a,b){for(var c=0,e=b.length;c<e;){var g=b[c++],f=b[c++];a=a.then(g,f)}b.length=0;return a}
+function e(a,b){var c,d={};p(a,function(a,e){A(a)?(c=a(b),null!=c&&(d[e]=c)):d[e]=a});return d}function g(a){var b=P({},a);b.data=yd(a.data,a.headers,a.status,f.transformResponse);a=a.status;return 200<=a&&300>a?b:h.reject(b)}if(!E(b))throw M("$http")("badreq",b);if(!D(m.valueOf(b.url)))throw M("$http")("badreq",b.url);var f=P({method:"get",transformRequest:a.transformRequest,transformResponse:a.transformResponse,paramSerializer:a.paramSerializer,jsonpCallbackParam:a.jsonpCallbackParam},b);f.headers=
+function(b){var c=a.headers,d=P({},b.headers),g,f,h,c=P({},c.common,c[N(b.method)]);a:for(g in c){f=N(g);for(h in d)if(N(h)===f)continue a;d[g]=c[g]}return e(d,ja(b))}(b);f.method=wb(f.method);f.paramSerializer=D(f.paramSerializer)?l.get(f.paramSerializer):f.paramSerializer;c.$$incOutstandingRequestCount();var k=[],n=[];b=h.resolve(f);p(v,function(a){(a.request||a.requestError)&&k.unshift(a.request,a.requestError);(a.response||a.responseError)&&n.push(a.response,a.responseError)});b=d(b,k);b=b.then(function(b){var c=
+b.headers,d=yd(b.data,xd(c),void 0,b.transformRequest);w(d)&&p(c,function(a,b){"content-type"===N(b)&&delete c[b]});w(b.withCredentials)&&!w(a.withCredentials)&&(b.withCredentials=a.withCredentials);return q(b,d).then(g,g)});b=d(b,n);return b=b.finally(function(){c.$$completeOutstandingRequest(C)})}function q(c,d){function g(a){if(a){var c={};p(a,function(a,d){c[d]=function(c){function d(){a(c)}b?k.$applyAsync(d):k.$$phase?d():k.$apply(d)}});return c}}function l(a,c,d,e,g){function f(){q(c,a,d,e,
+g)}R&&(200<=a&&300>a?R.put(O,[a,c,wd(d),e,g]):R.remove(O));b?k.$applyAsync(f):(f(),k.$$phase||k.$apply())}function q(a,b,d,e,g){b=-1<=b?b:0;(200<=b&&300>b?K.resolve:K.reject)({data:a,status:b,headers:xd(d),config:c,statusText:e,xhrStatus:g})}function H(a){q(a.data,a.status,ja(a.headers()),a.statusText,a.xhrStatus)}function v(){var a=n.pendingRequests.indexOf(c);-1!==a&&n.pendingRequests.splice(a,1)}var K=h.defer(),F=K.promise,R,x,W=c.headers,r="jsonp"===N(c.method),O=c.url;r?O=m.getTrustedResourceUrl(O):
+D(O)||(O=m.valueOf(O));O=G(O,c.paramSerializer(c.params));r&&(O=L(O,c.jsonpCallbackParam));n.pendingRequests.push(c);F.then(v,v);!c.cache&&!a.cache||!1===c.cache||"GET"!==c.method&&"JSONP"!==c.method||(R=E(c.cache)?c.cache:E(a.cache)?a.cache:z);R&&(x=R.get(O),t(x)?x&&A(x.then)?x.then(H,H):I(x)?q(x[1],x[0],ja(x[2]),x[3],x[4]):q(x,200,{},"OK","complete"):R.put(O,F));w(x)&&((x=zd(c.url)?f()[c.xsrfCookieName||a.xsrfCookieName]:void 0)&&(W[c.xsrfHeaderName||a.xsrfHeaderName]=x),e(c.method,O,d,l,W,c.timeout,
+c.withCredentials,c.responseType,g(c.eventHandlers),g(c.uploadEventHandlers)));return F}function G(a,b){0<b.length&&(a+=(-1===a.indexOf("?")?"?":"&")+b);return a}function L(a,b){if(/[&?][^=]+=JSON_CALLBACK/.test(a))throw rc("badjsonp",a);if((new RegExp("[&?]"+b+"=")).test(a))throw rc("badjsonp",b,a);return a+=(-1===a.indexOf("?")?"?":"&")+b+"=JSON_CALLBACK"}var z=g("$http");a.paramSerializer=D(a.paramSerializer)?l.get(a.paramSerializer):a.paramSerializer;var v=[];p(d,function(a){v.unshift(D(a)?l.get(a):
+l.invoke(a))});n.pendingRequests=[];(function(a){p(arguments,function(a){n[a]=function(b,c){return n(P({},c||{},{method:a,url:b}))}})})("get","delete","head","jsonp");(function(a){p(arguments,function(a){n[a]=function(b,c,d){return n(P({},d||{},{method:a,url:b,data:c}))}})})("post","put","patch");n.defaults=a;return n}]}function Jf(){this.$get=function(){return function(){return new u.XMLHttpRequest}}}function If(){this.$get=["$browser","$jsonpCallbacks","$document","$xhrFactory",function(a,b,d,c){return yg(a,
+c,a.defer,b,d[0])}]}function yg(a,b,d,c,e){function f(a,b,d){a=a.replace("JSON_CALLBACK",b);var f=e.createElement("script"),m=null;f.type="text/javascript";f.src=a;f.async=!0;m=function(a){f.removeEventListener("load",m);f.removeEventListener("error",m);e.body.removeChild(f);f=null;var g=-1,G="unknown";a&&("load"!==a.type||c.wasCalled(b)||(a={type:"error"}),G=a.type,g="error"===a.type?404:200);d&&d(g,G)};f.addEventListener("load",m);f.addEventListener("error",m);e.body.appendChild(f);return m}return function(e,
+k,h,l,m,n,q,G,L,z){function v(){ma&&ma();y&&y.abort()}function s(a,b,c,e,g,f){t(H)&&d.cancel(H);ma=y=null;a(b,c,e,g,f)}k=k||a.url();if("jsonp"===N(e))var r=c.createCallback(k),ma=f(k,r,function(a,b){var d=200===a&&c.getResponse(r);s(l,a,d,"",b,"complete");c.removeCallback(r)});else{var y=b(e,k);y.open(e,k,!0);p(m,function(a,b){t(a)&&y.setRequestHeader(b,a)});y.onload=function(){var a=y.statusText||"",b="response"in y?y.response:y.responseText,c=1223===y.status?204:y.status;0===c&&(c=b?200:"file"===
+ua(k).protocol?404:0);s(l,c,b,y.getAllResponseHeaders(),a,"complete")};y.onerror=function(){s(l,-1,null,null,"","error")};y.onabort=function(){s(l,-1,null,null,"","abort")};y.ontimeout=function(){s(l,-1,null,null,"","timeout")};p(L,function(a,b){y.addEventListener(b,a)});p(z,function(a,b){y.upload.addEventListener(b,a)});q&&(y.withCredentials=!0);if(G)try{y.responseType=G}catch(J){if("json"!==G)throw J;}y.send(w(h)?null:h)}if(0<n)var H=d(v,n);else n&&A(n.then)&&n.then(v)}}function Df(){var a="{{",
+b="}}";this.startSymbol=function(b){return b?(a=b,this):a};this.endSymbol=function(a){return a?(b=a,this):b};this.$get=["$parse","$exceptionHandler","$sce",function(d,c,e){function f(a){return"\\\\\\"+a}function g(c){return c.replace(n,a).replace(q,b)}function k(a,b,c,d){var e=a.$watch(function(a){e();return d(a)},b,c);return e}function h(f,h,q,n){function s(a){try{var b=a;a=q?e.getTrusted(q,b):e.valueOf(b);return n&&!t(a)?a:dc(a)}catch(d){c(Fa.interr(f,d))}}if(!f.length||-1===f.indexOf(a)){var p;
+h||(h=g(f),p=ka(h),p.exp=f,p.expressions=[],p.$$watchDelegate=k);return p}n=!!n;var r,y,J=0,H=[],ta=[];p=f.length;for(var K=[],F=[];J<p;)if(-1!==(r=f.indexOf(a,J))&&-1!==(y=f.indexOf(b,r+l)))J!==r&&K.push(g(f.substring(J,r))),J=f.substring(r+l,y),H.push(J),ta.push(d(J,s)),J=y+m,F.push(K.length),K.push("");else{J!==p&&K.push(g(f.substring(J)));break}q&&1<K.length&&Fa.throwNoconcat(f);if(!h||H.length){var R=function(a){for(var b=0,c=H.length;b<c;b++){if(n&&w(a[b]))return;K[F[b]]=a[b]}return K.join("")};
+return P(function(a){var b=0,d=H.length,e=Array(d);try{for(;b<d;b++)e[b]=ta[b](a);return R(e)}catch(g){c(Fa.interr(f,g))}},{exp:f,expressions:H,$$watchDelegate:function(a,b){var c;return a.$watchGroup(ta,function(d,e){var g=R(d);A(b)&&b.call(this,g,d!==e?c:g,a);c=g})}})}}var l=a.length,m=b.length,n=new RegExp(a.replace(/./g,f),"g"),q=new RegExp(b.replace(/./g,f),"g");h.startSymbol=function(){return a};h.endSymbol=function(){return b};return h}]}function Ef(){this.$get=["$rootScope","$window","$q",
+"$$q","$browser",function(a,b,d,c,e){function f(f,h,l,m){function n(){q?f.apply(null,G):f(v)}var q=4<arguments.length,G=q?ya.call(arguments,4):[],L=b.setInterval,p=b.clearInterval,v=0,s=t(m)&&!m,r=(s?c:d).defer(),ma=r.promise;l=t(l)?l:0;ma.$$intervalId=L(function(){s?e.defer(n):a.$evalAsync(n);r.notify(v++);0<l&&v>=l&&(r.resolve(v),p(ma.$$intervalId),delete g[ma.$$intervalId]);s||a.$apply()},h);g[ma.$$intervalId]=r;return ma}var g={};f.cancel=function(a){return a&&a.$$intervalId in g?(g[a.$$intervalId].promise.$$state.pur=
+!0,g[a.$$intervalId].reject("canceled"),b.clearInterval(a.$$intervalId),delete g[a.$$intervalId],!0):!1};return f}]}function tc(a){a=a.split("/");for(var b=a.length;b--;)a[b]=gb(a[b]);return a.join("/")}function Ad(a,b){var d=ua(a);b.$$protocol=d.protocol;b.$$host=d.hostname;b.$$port=Z(d.port)||zg[d.protocol]||null}function Bd(a,b){if(Ag.test(a))throw mb("badpath",a);var d="/"!==a.charAt(0);d&&(a="/"+a);var c=ua(a);b.$$path=decodeURIComponent(d&&"/"===c.pathname.charAt(0)?c.pathname.substring(1):
+c.pathname);b.$$search=Tc(c.search);b.$$hash=decodeURIComponent(c.hash);b.$$path&&"/"!==b.$$path.charAt(0)&&(b.$$path="/"+b.$$path)}function uc(a,b){return a.slice(0,b.length)===b}function va(a,b){if(uc(b,a))return b.substr(a.length)}function La(a){var b=a.indexOf("#");return-1===b?a:a.substr(0,b)}function nb(a){return a.replace(/(#.+)|#$/,"$1")}function vc(a,b,d){this.$$html5=!0;d=d||"";Ad(a,this);this.$$parse=function(a){var d=va(b,a);if(!D(d))throw mb("ipthprfx",a,b);Bd(d,this);this.$$path||(this.$$path=
+"/");this.$$compose()};this.$$compose=function(){var a=cc(this.$$search),d=this.$$hash?"#"+gb(this.$$hash):"";this.$$url=tc(this.$$path)+(a?"?"+a:"")+d;this.$$absUrl=b+this.$$url.substr(1);this.$$urlUpdatedByLocation=!0};this.$$parseLinkUrl=function(c,e){if(e&&"#"===e[0])return this.hash(e.slice(1)),!0;var f,g;t(f=va(a,c))?(g=f,g=d&&t(f=va(d,f))?b+(va("/",f)||f):a+g):t(f=va(b,c))?g=b+f:b===c+"/"&&(g=b);g&&this.$$parse(g);return!!g}}function wc(a,b,d){Ad(a,this);this.$$parse=function(c){var e=va(a,
+c)||va(b,c),f;w(e)||"#"!==e.charAt(0)?this.$$html5?f=e:(f="",w(e)&&(a=c,this.replace())):(f=va(d,e),w(f)&&(f=e));Bd(f,this);c=this.$$path;var e=a,g=/^\/[A-Z]:(\/.*)/;uc(f,e)&&(f=f.replace(e,""));g.exec(f)||(c=(f=g.exec(c))?f[1]:c);this.$$path=c;this.$$compose()};this.$$compose=function(){var b=cc(this.$$search),e=this.$$hash?"#"+gb(this.$$hash):"";this.$$url=tc(this.$$path)+(b?"?"+b:"")+e;this.$$absUrl=a+(this.$$url?d+this.$$url:"");this.$$urlUpdatedByLocation=!0};this.$$parseLinkUrl=function(b,d){return La(a)===
+La(b)?(this.$$parse(b),!0):!1}}function Cd(a,b,d){this.$$html5=!0;wc.apply(this,arguments);this.$$parseLinkUrl=function(c,e){if(e&&"#"===e[0])return this.hash(e.slice(1)),!0;var f,g;a===La(c)?f=c:(g=va(b,c))?f=a+d+g:b===c+"/"&&(f=b);f&&this.$$parse(f);return!!f};this.$$compose=function(){var b=cc(this.$$search),e=this.$$hash?"#"+gb(this.$$hash):"";this.$$url=tc(this.$$path)+(b?"?"+b:"")+e;this.$$absUrl=a+d+this.$$url;this.$$urlUpdatedByLocation=!0}}function Lb(a){return function(){return this[a]}}
+function Dd(a,b){return function(d){if(w(d))return this[a];this[a]=b(d);this.$$compose();return this}}function Lf(){var a="!",b={enabled:!1,requireBase:!0,rewriteLinks:!0};this.hashPrefix=function(b){return t(b)?(a=b,this):a};this.html5Mode=function(a){if(Na(a))return b.enabled=a,this;if(E(a)){Na(a.enabled)&&(b.enabled=a.enabled);Na(a.requireBase)&&(b.requireBase=a.requireBase);if(Na(a.rewriteLinks)||D(a.rewriteLinks))b.rewriteLinks=a.rewriteLinks;return this}return b};this.$get=["$rootScope","$browser",
+"$sniffer","$rootElement","$window",function(d,c,e,f,g){function k(a,b,d){var e=l.url(),g=l.$$state;try{c.url(a,b,d),l.$$state=c.state()}catch(f){throw l.url(e),l.$$state=g,f;}}function h(a,b){d.$broadcast("$locationChangeSuccess",l.absUrl(),a,l.$$state,b)}var l,m;m=c.baseHref();var n=c.url(),q;if(b.enabled){if(!m&&b.requireBase)throw mb("nobase");q=n.substring(0,n.indexOf("/",n.indexOf("//")+2))+(m||"/");m=e.history?vc:Cd}else q=La(n),m=wc;var G=q.substr(0,La(q).lastIndexOf("/")+1);l=new m(q,G,"#"+
+a);l.$$parseLinkUrl(n,n);l.$$state=c.state();var p=/^\s*(javascript|mailto):/i;f.on("click",function(a){var e=b.rewriteLinks;if(e&&!a.ctrlKey&&!a.metaKey&&!a.shiftKey&&2!==a.which&&2!==a.button){for(var h=B(a.target);"a"!==za(h[0]);)if(h[0]===f[0]||!(h=h.parent())[0])return;if(!D(e)||!w(h.attr(e))){var e=h.prop("href"),k=h.attr("href")||h.attr("xlink:href");E(e)&&"[object SVGAnimatedString]"===e.toString()&&(e=ua(e.animVal).href);p.test(e)||!e||h.attr("target")||a.isDefaultPrevented()||!l.$$parseLinkUrl(e,
+k)||(a.preventDefault(),l.absUrl()!==c.url()&&(d.$apply(),g.angular["ff-684208-preventDefault"]=!0))}}});nb(l.absUrl())!==nb(n)&&c.url(l.absUrl(),!0);var z=!0;c.onUrlChange(function(a,b){uc(a,G)?(d.$evalAsync(function(){var c=l.absUrl(),e=l.$$state,g;a=nb(a);l.$$parse(a);l.$$state=b;g=d.$broadcast("$locationChangeStart",a,c,b,e).defaultPrevented;l.absUrl()===a&&(g?(l.$$parse(c),l.$$state=e,k(c,!1,e)):(z=!1,h(c,e)))}),d.$$phase||d.$digest()):g.location.href=a});d.$watch(function(){if(z||l.$$urlUpdatedByLocation){l.$$urlUpdatedByLocation=
+!1;var a=nb(c.url()),b=nb(l.absUrl()),g=c.state(),f=l.$$replace,m=a!==b||l.$$html5&&e.history&&g!==l.$$state;if(z||m)z=!1,d.$evalAsync(function(){var b=l.absUrl(),c=d.$broadcast("$locationChangeStart",b,a,l.$$state,g).defaultPrevented;l.absUrl()===b&&(c?(l.$$parse(a),l.$$state=g):(m&&k(b,f,g===l.$$state?null:l.$$state),h(a,g)))})}l.$$replace=!1});return l}]}function Mf(){var a=!0,b=this;this.debugEnabled=function(b){return t(b)?(a=b,this):a};this.$get=["$window",function(d){function c(a){$b(a)&&(a.stack&&
+f?a=a.message&&-1===a.stack.indexOf(a.message)?"Error: "+a.message+"\n"+a.stack:a.stack:a.sourceURL&&(a=a.message+"\n"+a.sourceURL+":"+a.line));return a}function e(a){var b=d.console||{},e=b[a]||b.log||C;return function(){var a=[];p(arguments,function(b){a.push(c(b))});return Function.prototype.apply.call(e,b,a)}}var f=Ca||/\bEdge\//.test(d.navigator&&d.navigator.userAgent);return{log:e("log"),info:e("info"),warn:e("warn"),error:e("error"),debug:function(){var c=e("debug");return function(){a&&c.apply(b,
+arguments)}}()}}]}function Bg(a){return a+""}function Cg(a,b){return"undefined"!==typeof a?a:b}function Ed(a,b){return"undefined"===typeof a?b:"undefined"===typeof b?a:a+b}function Dg(a,b){switch(a.type){case r.MemberExpression:if(a.computed)return!1;break;case r.UnaryExpression:return 1;case r.BinaryExpression:return"+"!==a.operator?1:!1;case r.CallExpression:return!1}return void 0===b?Fd:b}function V(a,b,d){var c,e,f=a.isPure=Dg(a,d);switch(a.type){case r.Program:c=!0;p(a.body,function(a){V(a.expression,
+b,f);c=c&&a.expression.constant});a.constant=c;break;case r.Literal:a.constant=!0;a.toWatch=[];break;case r.UnaryExpression:V(a.argument,b,f);a.constant=a.argument.constant;a.toWatch=a.argument.toWatch;break;case r.BinaryExpression:V(a.left,b,f);V(a.right,b,f);a.constant=a.left.constant&&a.right.constant;a.toWatch=a.left.toWatch.concat(a.right.toWatch);break;case r.LogicalExpression:V(a.left,b,f);V(a.right,b,f);a.constant=a.left.constant&&a.right.constant;a.toWatch=a.constant?[]:[a];break;case r.ConditionalExpression:V(a.test,
+b,f);V(a.alternate,b,f);V(a.consequent,b,f);a.constant=a.test.constant&&a.alternate.constant&&a.consequent.constant;a.toWatch=a.constant?[]:[a];break;case r.Identifier:a.constant=!1;a.toWatch=[a];break;case r.MemberExpression:V(a.object,b,f);a.computed&&V(a.property,b,f);a.constant=a.object.constant&&(!a.computed||a.property.constant);a.toWatch=a.constant?[]:[a];break;case r.CallExpression:c=d=a.filter?!b(a.callee.name).$stateful:!1;e=[];p(a.arguments,function(a){V(a,b,f);c=c&&a.constant;e.push.apply(e,
+a.toWatch)});a.constant=c;a.toWatch=d?e:[a];break;case r.AssignmentExpression:V(a.left,b,f);V(a.right,b,f);a.constant=a.left.constant&&a.right.constant;a.toWatch=[a];break;case r.ArrayExpression:c=!0;e=[];p(a.elements,function(a){V(a,b,f);c=c&&a.constant;e.push.apply(e,a.toWatch)});a.constant=c;a.toWatch=e;break;case r.ObjectExpression:c=!0;e=[];p(a.properties,function(a){V(a.value,b,f);c=c&&a.value.constant;e.push.apply(e,a.value.toWatch);a.computed&&(V(a.key,b,!1),c=c&&a.key.constant,e.push.apply(e,
+a.key.toWatch))});a.constant=c;a.toWatch=e;break;case r.ThisExpression:a.constant=!1;a.toWatch=[];break;case r.LocalsExpression:a.constant=!1,a.toWatch=[]}}function Gd(a){if(1===a.length){a=a[0].expression;var b=a.toWatch;return 1!==b.length?b:b[0]!==a?b:void 0}}function Hd(a){return a.type===r.Identifier||a.type===r.MemberExpression}function Id(a){if(1===a.body.length&&Hd(a.body[0].expression))return{type:r.AssignmentExpression,left:a.body[0].expression,right:{type:r.NGValueParameter},operator:"="}}
+function Jd(a){this.$filter=a}function Kd(a){this.$filter=a}function xc(a,b,d){this.ast=new r(a,d);this.astCompiler=d.csp?new Kd(b):new Jd(b)}function yc(a){return A(a.valueOf)?a.valueOf():Eg.call(a)}function Nf(){var a=S(),b={"true":!0,"false":!1,"null":null,undefined:void 0},d,c;this.addLiteral=function(a,c){b[a]=c};this.setIdentifierFns=function(a,b){d=a;c=b;return this};this.$get=["$filter",function(e){function f(a,b,c){return null==a||null==b?a===b:"object"!==typeof a||(a=yc(a),"object"!==typeof a||
+c)?a===b||a!==a&&b!==b:!1}function g(a,b,c,d,e){var g=d.inputs,h;if(1===g.length){var k=f,g=g[0];return a.$watch(function(a){var b=g(a);f(b,k,g.isPure)||(h=d(a,void 0,void 0,[b]),k=b&&yc(b));return h},b,c,e)}for(var l=[],m=[],n=0,p=g.length;n<p;n++)l[n]=f,m[n]=null;return a.$watch(function(a){for(var b=!1,c=0,e=g.length;c<e;c++){var k=g[c](a);if(b||(b=!f(k,l[c],g[c].isPure)))m[c]=k,l[c]=k&&yc(k)}b&&(h=d(a,void 0,void 0,m));return h},b,c,e)}function k(a,b,c,d,e){function f(a){return d(a)}function h(a,
+c,d){l=a;A(b)&&b(a,c,d);t(a)&&d.$$postDigest(function(){t(l)&&k()})}var k,l;return k=d.inputs?g(a,h,c,d,e):a.$watch(f,h,c)}function h(a,b,c,d){function e(a){var b=!0;p(a,function(a){t(a)||(b=!1)});return b}var g,f;return g=a.$watch(function(a){return d(a)},function(a,c,d){f=a;A(b)&&b(a,c,d);e(a)&&d.$$postDigest(function(){e(f)&&g()})},c)}function l(a,b,c,d){var e=a.$watch(function(a){e();return d(a)},b,c);return e}function m(a,b){if(!b)return a;var c=a.$$watchDelegate,d=!1,e=c!==h&&c!==k?function(c,
+e,g,f){g=d&&f?f[0]:a(c,e,g,f);return b(g,c,e)}:function(c,d,e,g){e=a(c,d,e,g);c=b(e,c,d);return t(e)?c:e},d=!a.inputs;c&&c!==g?(e.$$watchDelegate=c,e.inputs=a.inputs):b.$stateful||(e.$$watchDelegate=g,e.inputs=a.inputs?a.inputs:[a]);e.inputs&&(e.inputs=e.inputs.map(function(a){return a.isPure===Fd?function(b){return a(b)}:a}));return e}var n={csp:Ja().noUnsafeEval,literals:pa(b),isIdentifierStart:A(d)&&d,isIdentifierContinue:A(c)&&c};return function(b,c){var d,f,p;switch(typeof b){case "string":return p=
+b=b.trim(),d=a[p],d||(":"===b.charAt(0)&&":"===b.charAt(1)&&(f=!0,b=b.substring(2)),d=new zc(n),d=(new xc(d,e,n)).parse(b),d.constant?d.$$watchDelegate=l:f?d.$$watchDelegate=d.literal?h:k:d.inputs&&(d.$$watchDelegate=g),a[p]=d),m(d,c);case "function":return m(b,c);default:return m(C,c)}}}]}function Pf(){var a=!0;this.$get=["$rootScope","$exceptionHandler",function(b,d){return Ld(function(a){b.$evalAsync(a)},d,a)}];this.errorOnUnhandledRejections=function(b){return t(b)?(a=b,this):a}}function Qf(){var a=
+!0;this.$get=["$browser","$exceptionHandler",function(b,d){return Ld(function(a){b.defer(a)},d,a)}];this.errorOnUnhandledRejections=function(b){return t(b)?(a=b,this):a}}function Ld(a,b,d){function c(){return new e}function e(){var a=this.promise=new f;this.resolve=function(b){h(a,b)};this.reject=function(b){m(a,b)};this.notify=function(b){q(a,b)}}function f(){this.$$state={status:0}}function g(){for(;!t&&u.length;){var a=u.shift();if(!a.pur){a.pur=!0;var c=a.value,c="Possibly unhandled rejection: "+
+("function"===typeof c?c.toString().replace(/ \{[\s\S]*$/,""):w(c)?"undefined":"string"!==typeof c?De(c,void 0):c);$b(a.value)?b(a.value,c):b(c)}}}function k(b){!d||b.pending||2!==b.status||b.pur||(0===t&&0===u.length&&a(g),u.push(b));!b.processScheduled&&b.pending&&(b.processScheduled=!0,++t,a(function(){var c,e,f;f=b.pending;b.processScheduled=!1;b.pending=void 0;try{for(var k=0,l=f.length;k<l;++k){b.pur=!0;e=f[k][0];c=f[k][b.status];try{A(c)?h(e,c(b.value)):1===b.status?h(e,b.value):m(e,b.value)}catch(n){m(e,
+n)}}}finally{--t,d&&0===t&&a(g)}}))}function h(a,b){a.$$state.status||(b===a?n(a,s("qcycle",b)):l(a,b))}function l(a,b){function c(b){f||(f=!0,l(a,b))}function d(b){f||(f=!0,n(a,b))}function e(b){q(a,b)}var g,f=!1;try{if(E(b)||A(b))g=b.then;A(g)?(a.$$state.status=-1,g.call(b,c,d,e)):(a.$$state.value=b,a.$$state.status=1,k(a.$$state))}catch(h){d(h)}}function m(a,b){a.$$state.status||n(a,b)}function n(a,b){a.$$state.value=b;a.$$state.status=2;k(a.$$state)}function q(c,d){var e=c.$$state.pending;0>=
+c.$$state.status&&e&&e.length&&a(function(){for(var a,c,g=0,f=e.length;g<f;g++){c=e[g][0];a=e[g][3];try{q(c,A(a)?a(d):d)}catch(h){b(h)}}})}function G(a){var b=new f;m(b,a);return b}function r(a,b,c){var d=null;try{A(c)&&(d=c())}catch(e){return G(e)}return d&&A(d.then)?d.then(function(){return b(a)},G):b(a)}function z(a,b,c,d){var e=new f;h(e,a);return e.then(b,c,d)}function v(a){if(!A(a))throw s("norslvr",a);var b=new f;a(function(a){h(b,a)},function(a){m(b,a)});return b}var s=M("$q",TypeError),t=
+0,u=[];P(f.prototype,{then:function(a,b,c){if(w(a)&&w(b)&&w(c))return this;var d=new f;this.$$state.pending=this.$$state.pending||[];this.$$state.pending.push([d,a,b,c]);0<this.$$state.status&&k(this.$$state);return d},"catch":function(a){return this.then(null,a)},"finally":function(a,b){return this.then(function(b){return r(b,y,a)},function(b){return r(b,G,a)},b)}});var y=z;v.prototype=f.prototype;v.defer=c;v.reject=G;v.when=z;v.resolve=y;v.all=function(a){var b=new f,c=0,d=I(a)?[]:{};p(a,function(a,
+e){c++;z(a).then(function(a){d[e]=a;--c||h(b,d)},function(a){m(b,a)})});0===c&&h(b,d);return b};v.race=function(a){var b=c();p(a,function(a){z(a).then(b.resolve,b.reject)});return b.promise};return v}function Zf(){this.$get=["$window","$timeout",function(a,b){var d=a.requestAnimationFrame||a.webkitRequestAnimationFrame,c=a.cancelAnimationFrame||a.webkitCancelAnimationFrame||a.webkitCancelRequestAnimationFrame,e=!!d,f=e?function(a){var b=d(a);return function(){c(b)}}:function(a){var c=b(a,16.66,!1);
+return function(){b.cancel(c)}};f.supported=e;return f}]}function Of(){function a(a){function b(){this.$$watchers=this.$$nextSibling=this.$$childHead=this.$$childTail=null;this.$$listeners={};this.$$listenerCount={};this.$$watchersCount=0;this.$id=++sb;this.$$ChildScope=null}b.prototype=a;return b}var b=10,d=M("$rootScope"),c=null,e=null;this.digestTtl=function(a){arguments.length&&(b=a);return b};this.$get=["$exceptionHandler","$parse","$browser",function(f,g,k){function h(a){a.currentScope.$$destroyed=
+!0}function l(a){9===Ca&&(a.$$childHead&&l(a.$$childHead),a.$$nextSibling&&l(a.$$nextSibling));a.$parent=a.$$nextSibling=a.$$prevSibling=a.$$childHead=a.$$childTail=a.$root=a.$$watchers=null}function m(){this.$id=++sb;this.$$phase=this.$parent=this.$$watchers=this.$$nextSibling=this.$$prevSibling=this.$$childHead=this.$$childTail=null;this.$root=this;this.$$destroyed=!1;this.$$listeners={};this.$$listenerCount={};this.$$watchersCount=0;this.$$isolateBindings=null}function n(a){if(s.$$phase)throw d("inprog",
+s.$$phase);s.$$phase=a}function q(a,b){do a.$$watchersCount+=b;while(a=a.$parent)}function G(a,b,c){do a.$$listenerCount[c]-=b,0===a.$$listenerCount[c]&&delete a.$$listenerCount[c];while(a=a.$parent)}function r(){}function z(){for(;y.length;)try{y.shift()()}catch(a){f(a)}e=null}function v(){null===e&&(e=k.defer(function(){s.$apply(z)}))}m.prototype={constructor:m,$new:function(b,c){var d;c=c||this;b?(d=new m,d.$root=this.$root):(this.$$ChildScope||(this.$$ChildScope=a(this)),d=new this.$$ChildScope);
+d.$parent=c;d.$$prevSibling=c.$$childTail;c.$$childHead?(c.$$childTail.$$nextSibling=d,c.$$childTail=d):c.$$childHead=c.$$childTail=d;(b||c!==this)&&d.$on("$destroy",h);return d},$watch:function(a,b,d,e){var f=g(a);if(f.$$watchDelegate)return f.$$watchDelegate(this,b,d,f,a);var h=this,k=h.$$watchers,l={fn:b,last:r,get:f,exp:e||a,eq:!!d};c=null;A(b)||(l.fn=C);k||(k=h.$$watchers=[],k.$$digestWatchIndex=-1);k.unshift(l);k.$$digestWatchIndex++;q(this,1);return function(){var a=db(k,l);0<=a&&(q(h,-1),
+a<k.$$digestWatchIndex&&k.$$digestWatchIndex--);c=null}},$watchGroup:function(a,b){function c(){h=!1;k?(k=!1,b(e,e,f)):b(e,d,f)}var d=Array(a.length),e=Array(a.length),g=[],f=this,h=!1,k=!0;if(!a.length){var l=!0;f.$evalAsync(function(){l&&b(e,e,f)});return function(){l=!1}}if(1===a.length)return this.$watch(a[0],function(a,c,g){e[0]=a;d[0]=c;b(e,a===c?e:d,g)});p(a,function(a,b){var k=f.$watch(a,function(a,g){e[b]=a;d[b]=g;h||(h=!0,f.$evalAsync(c))});g.push(k)});return function(){for(;g.length;)g.shift()()}},
+$watchCollection:function(a,b){function c(a){e=a;var b,d,g,h;if(!w(e)){if(E(e))if(xa(e))for(f!==n&&(f=n,p=f.length=0,l++),a=e.length,p!==a&&(l++,f.length=p=a),b=0;b<a;b++)h=f[b],g=e[b],d=h!==h&&g!==g,d||h===g||(l++,f[b]=g);else{f!==q&&(f=q={},p=0,l++);a=0;for(b in e)ra.call(e,b)&&(a++,g=e[b],h=f[b],b in f?(d=h!==h&&g!==g,d||h===g||(l++,f[b]=g)):(p++,f[b]=g,l++));if(p>a)for(b in l++,f)ra.call(e,b)||(p--,delete f[b])}else f!==e&&(f=e,l++);return l}}c.$stateful=!0;var d=this,e,f,h,k=1<b.length,l=0,m=
+g(a,c),n=[],q={},s=!0,p=0;return this.$watch(m,function(){s?(s=!1,b(e,e,d)):b(e,h,d);if(k)if(E(e))if(xa(e)){h=Array(e.length);for(var a=0;a<e.length;a++)h[a]=e[a]}else for(a in h={},e)ra.call(e,a)&&(h[a]=e[a]);else h=e})},$digest:function(){var a,g,h,l,m,q,p,G=b,y,v=[],w,B;n("$digest");k.$$checkUrlChange();this===s&&null!==e&&(k.defer.cancel(e),z());c=null;do{p=!1;y=this;for(q=0;q<t.length;q++){try{B=t[q],l=B.fn,l(B.scope,B.locals)}catch(C){f(C)}c=null}t.length=0;a:do{if(q=y.$$watchers)for(q.$$digestWatchIndex=
+q.length;q.$$digestWatchIndex--;)try{if(a=q[q.$$digestWatchIndex])if(m=a.get,(g=m(y))!==(h=a.last)&&!(a.eq?sa(g,h):T(g)&&T(h)))p=!0,c=a,a.last=a.eq?pa(g,null):g,l=a.fn,l(g,h===r?g:h,y),5>G&&(w=4-G,v[w]||(v[w]=[]),v[w].push({msg:A(a.exp)?"fn: "+(a.exp.name||a.exp.toString()):a.exp,newVal:g,oldVal:h}));else if(a===c){p=!1;break a}}catch(E){f(E)}if(!(q=y.$$watchersCount&&y.$$childHead||y!==this&&y.$$nextSibling))for(;y!==this&&!(q=y.$$nextSibling);)y=y.$parent}while(y=q);if((p||t.length)&&!G--)throw s.$$phase=
+null,d("infdig",b,v);}while(p||t.length);for(s.$$phase=null;J<u.length;)try{u[J++]()}catch(D){f(D)}u.length=J=0;k.$$checkUrlChange()},$destroy:function(){if(!this.$$destroyed){var a=this.$parent;this.$broadcast("$destroy");this.$$destroyed=!0;this===s&&k.$$applicationDestroyed();q(this,-this.$$watchersCount);for(var b in this.$$listenerCount)G(this,this.$$listenerCount[b],b);a&&a.$$childHead===this&&(a.$$childHead=this.$$nextSibling);a&&a.$$childTail===this&&(a.$$childTail=this.$$prevSibling);this.$$prevSibling&&
+(this.$$prevSibling.$$nextSibling=this.$$nextSibling);this.$$nextSibling&&(this.$$nextSibling.$$prevSibling=this.$$prevSibling);this.$destroy=this.$digest=this.$apply=this.$evalAsync=this.$applyAsync=C;this.$on=this.$watch=this.$watchGroup=function(){return C};this.$$listeners={};this.$$nextSibling=null;l(this)}},$eval:function(a,b){return g(a)(this,b)},$evalAsync:function(a,b){s.$$phase||t.length||k.defer(function(){t.length&&s.$digest()});t.push({scope:this,fn:g(a),locals:b})},$$postDigest:function(a){u.push(a)},
+$apply:function(a){try{n("$apply");try{return this.$eval(a)}finally{s.$$phase=null}}catch(b){f(b)}finally{try{s.$digest()}catch(c){throw f(c),c;}}},$applyAsync:function(a){function b(){c.$eval(a)}var c=this;a&&y.push(b);a=g(a);v()},$on:function(a,b){var c=this.$$listeners[a];c||(this.$$listeners[a]=c=[]);c.push(b);var d=this;do d.$$listenerCount[a]||(d.$$listenerCount[a]=0),d.$$listenerCount[a]++;while(d=d.$parent);var e=this;return function(){var d=c.indexOf(b);-1!==d&&(c[d]=null,G(e,1,a))}},$emit:function(a,
+b){var c=[],d,e=this,g=!1,h={name:a,targetScope:e,stopPropagation:function(){g=!0},preventDefault:function(){h.defaultPrevented=!0},defaultPrevented:!1},k=eb([h],arguments,1),l,m;do{d=e.$$listeners[a]||c;h.currentScope=e;l=0;for(m=d.length;l<m;l++)if(d[l])try{d[l].apply(null,k)}catch(n){f(n)}else d.splice(l,1),l--,m--;if(g)return h.currentScope=null,h;e=e.$parent}while(e);h.currentScope=null;return h},$broadcast:function(a,b){var c=this,d=this,e={name:a,targetScope:this,preventDefault:function(){e.defaultPrevented=
+!0},defaultPrevented:!1};if(!this.$$listenerCount[a])return e;for(var g=eb([e],arguments,1),h,k;c=d;){e.currentScope=c;d=c.$$listeners[a]||[];h=0;for(k=d.length;h<k;h++)if(d[h])try{d[h].apply(null,g)}catch(l){f(l)}else d.splice(h,1),h--,k--;if(!(d=c.$$listenerCount[a]&&c.$$childHead||c!==this&&c.$$nextSibling))for(;c!==this&&!(d=c.$$nextSibling);)c=c.$parent}e.currentScope=null;return e}};var s=new m,t=s.$$asyncQueue=[],u=s.$$postDigestQueue=[],y=s.$$applyAsyncQueue=[],J=0;return s}]}function Ge(){var a=
+/^\s*(https?|ftp|mailto|tel|file):/,b=/^\s*((https?|ftp|file|blob):|data:image\/)/;this.aHrefSanitizationWhitelist=function(b){return t(b)?(a=b,this):a};this.imgSrcSanitizationWhitelist=function(a){return t(a)?(b=a,this):b};this.$get=function(){return function(d,c){var e=c?b:a,f;f=ua(d).href;return""===f||f.match(e)?d:"unsafe:"+f}}}function Fg(a){if("self"===a)return a;if(D(a)){if(-1<a.indexOf("***"))throw wa("iwcard",a);a=Md(a).replace(/\\\*\\\*/g,".*").replace(/\\\*/g,"[^:/.?&;]*");return new RegExp("^"+
+a+"$")}if(ab(a))return new RegExp("^"+a.source+"$");throw wa("imatcher");}function Nd(a){var b=[];t(a)&&p(a,function(a){b.push(Fg(a))});return b}function Sf(){this.SCE_CONTEXTS=oa;var a=["self"],b=[];this.resourceUrlWhitelist=function(b){arguments.length&&(a=Nd(b));return a};this.resourceUrlBlacklist=function(a){arguments.length&&(b=Nd(a));return b};this.$get=["$injector",function(d){function c(a,b){return"self"===a?zd(b):!!a.exec(b.href)}function e(a){var b=function(a){this.$$unwrapTrustedValue=
+function(){return a}};a&&(b.prototype=new a);b.prototype.valueOf=function(){return this.$$unwrapTrustedValue()};b.prototype.toString=function(){return this.$$unwrapTrustedValue().toString()};return b}var f=function(a){throw wa("unsafe");};d.has("$sanitize")&&(f=d.get("$sanitize"));var g=e(),k={};k[oa.HTML]=e(g);k[oa.CSS]=e(g);k[oa.URL]=e(g);k[oa.JS]=e(g);k[oa.RESOURCE_URL]=e(k[oa.URL]);return{trustAs:function(a,b){var c=k.hasOwnProperty(a)?k[a]:null;if(!c)throw wa("icontext",a,b);if(null===b||w(b)||
+""===b)return b;if("string"!==typeof b)throw wa("itype",a);return new c(b)},getTrusted:function(d,e){if(null===e||w(e)||""===e)return e;var g=k.hasOwnProperty(d)?k[d]:null;if(g&&e instanceof g)return e.$$unwrapTrustedValue();if(d===oa.RESOURCE_URL){var g=ua(e.toString()),n,q,p=!1;n=0;for(q=a.length;n<q;n++)if(c(a[n],g)){p=!0;break}if(p)for(n=0,q=b.length;n<q;n++)if(c(b[n],g)){p=!1;break}if(p)return e;throw wa("insecurl",e.toString());}if(d===oa.HTML)return f(e);throw wa("unsafe");},valueOf:function(a){return a instanceof
+g?a.$$unwrapTrustedValue():a}}}]}function Rf(){var a=!0;this.enabled=function(b){arguments.length&&(a=!!b);return a};this.$get=["$parse","$sceDelegate",function(b,d){if(a&&8>Ca)throw wa("iequirks");var c=ja(oa);c.isEnabled=function(){return a};c.trustAs=d.trustAs;c.getTrusted=d.getTrusted;c.valueOf=d.valueOf;a||(c.trustAs=c.getTrusted=function(a,b){return b},c.valueOf=bb);c.parseAs=function(a,d){var e=b(d);return e.literal&&e.constant?e:b(d,function(b){return c.getTrusted(a,b)})};var e=c.parseAs,
+f=c.getTrusted,g=c.trustAs;p(oa,function(a,b){var d=N(b);c[("parse_as_"+d).replace(Ac,jb)]=function(b){return e(a,b)};c[("get_trusted_"+d).replace(Ac,jb)]=function(b){return f(a,b)};c[("trust_as_"+d).replace(Ac,jb)]=function(b){return g(a,b)}});return c}]}function Tf(){this.$get=["$window","$document",function(a,b){var d={},c=!((!a.nw||!a.nw.process)&&a.chrome&&(a.chrome.app&&a.chrome.app.runtime||!a.chrome.app&&a.chrome.runtime&&a.chrome.runtime.id))&&a.history&&a.history.pushState,e=Z((/android (\d+)/.exec(N((a.navigator||
+{}).userAgent))||[])[1]),f=/Boxee/i.test((a.navigator||{}).userAgent),g=b[0]||{},k=g.body&&g.body.style,h=!1,l=!1;k&&(h=!!("transition"in k||"webkitTransition"in k),l=!!("animation"in k||"webkitAnimation"in k));return{history:!(!c||4>e||f),hasEvent:function(a){if("input"===a&&Ca)return!1;if(w(d[a])){var b=g.createElement("div");d[a]="on"+a in b}return d[a]},csp:Ja(),transitions:h,animations:l,android:e}}]}function Vf(){var a;this.httpOptions=function(b){return b?(a=b,this):a};this.$get=["$exceptionHandler",
+"$templateCache","$http","$q","$sce",function(b,d,c,e,f){function g(k,h){g.totalPendingRequests++;if(!D(k)||w(d.get(k)))k=f.getTrustedResourceUrl(k);var l=c.defaults&&c.defaults.transformResponse;I(l)?l=l.filter(function(a){return a!==qc}):l===qc&&(l=null);return c.get(k,P({cache:d,transformResponse:l},a)).finally(function(){g.totalPendingRequests--}).then(function(a){d.put(k,a.data);return a.data},function(a){h||(a=Gg("tpload",k,a.status,a.statusText),b(a));return e.reject(a)})}g.totalPendingRequests=
+0;return g}]}function Wf(){this.$get=["$rootScope","$browser","$location",function(a,b,d){return{findBindings:function(a,b,d){a=a.getElementsByClassName("ng-binding");var g=[];p(a,function(a){var c=$.element(a).data("$binding");c&&p(c,function(c){d?(new RegExp("(^|\\s)"+Md(b)+"(\\s|\\||$)")).test(c)&&g.push(a):-1!==c.indexOf(b)&&g.push(a)})});return g},findModels:function(a,b,d){for(var g=["ng-","data-ng-","ng\\:"],k=0;k<g.length;++k){var h=a.querySelectorAll("["+g[k]+"model"+(d?"=":"*=")+'"'+b+'"]');
+if(h.length)return h}},getLocation:function(){return d.url()},setLocation:function(b){b!==d.url()&&(d.url(b),a.$digest())},whenStable:function(a){b.notifyWhenNoOutstandingRequests(a)}}}]}function Xf(){this.$get=["$rootScope","$browser","$q","$$q","$exceptionHandler",function(a,b,d,c,e){function f(f,h,l){A(f)||(l=h,h=f,f=C);var m=ya.call(arguments,3),n=t(l)&&!l,q=(n?c:d).defer(),p=q.promise,r;r=b.defer(function(){try{q.resolve(f.apply(null,m))}catch(b){q.reject(b),e(b)}finally{delete g[p.$$timeoutId]}n||
+a.$apply()},h);p.$$timeoutId=r;g[r]=q;return p}var g={};f.cancel=function(a){return a&&a.$$timeoutId in g?(g[a.$$timeoutId].promise.$$state.pur=!0,g[a.$$timeoutId].reject("canceled"),delete g[a.$$timeoutId],b.defer.cancel(a.$$timeoutId)):!1};return f}]}function ua(a){Ca&&(X.setAttribute("href",a),a=X.href);X.setAttribute("href",a);return{href:X.href,protocol:X.protocol?X.protocol.replace(/:$/,""):"",host:X.host,search:X.search?X.search.replace(/^\?/,""):"",hash:X.hash?X.hash.replace(/^#/,""):"",hostname:X.hostname,
+port:X.port,pathname:"/"===X.pathname.charAt(0)?X.pathname:"/"+X.pathname}}function zd(a){a=D(a)?ua(a):a;return a.protocol===Od.protocol&&a.host===Od.host}function Yf(){this.$get=ka(u)}function Pd(a){function b(a){try{return decodeURIComponent(a)}catch(b){return a}}var d=a[0]||{},c={},e="";return function(){var a,g,k,h,l;try{a=d.cookie||""}catch(m){a=""}if(a!==e)for(e=a,a=e.split("; "),c={},k=0;k<a.length;k++)g=a[k],h=g.indexOf("="),0<h&&(l=b(g.substring(0,h)),w(c[l])&&(c[l]=b(g.substring(h+1))));
+return c}}function bg(){this.$get=Pd}function ed(a){function b(d,c){if(E(d)){var e={};p(d,function(a,c){e[c]=b(c,a)});return e}return a.factory(d+"Filter",c)}this.register=b;this.$get=["$injector",function(a){return function(b){return a.get(b+"Filter")}}];b("currency",Qd);b("date",Rd);b("filter",Hg);b("json",Ig);b("limitTo",Jg);b("lowercase",Kg);b("number",Sd);b("orderBy",Td);b("uppercase",Lg)}function Hg(){return function(a,b,d,c){if(!xa(a)){if(null==a)return a;throw M("filter")("notarray",a);}c=
+c||"$";var e;switch(Bc(b)){case "function":break;case "boolean":case "null":case "number":case "string":e=!0;case "object":b=Mg(b,d,c,e);break;default:return a}return Array.prototype.filter.call(a,b)}}function Mg(a,b,d,c){var e=E(a)&&d in a;!0===b?b=sa:A(b)||(b=function(a,b){if(w(a))return!1;if(null===a||null===b)return a===b;if(E(b)||E(a)&&!Zb(a))return!1;a=N(""+a);b=N(""+b);return-1!==a.indexOf(b)});return function(f){return e&&!E(f)?ga(f,a[d],b,d,!1):ga(f,a,b,d,c)}}function ga(a,b,d,c,e,f){var g=
+Bc(a),k=Bc(b);if("string"===k&&"!"===b.charAt(0))return!ga(a,b.substring(1),d,c,e);if(I(a))return a.some(function(a){return ga(a,b,d,c,e)});switch(g){case "object":var h;if(e){for(h in a)if(h.charAt&&"$"!==h.charAt(0)&&ga(a[h],b,d,c,!0))return!0;return f?!1:ga(a,b,d,c,!1)}if("object"===k){for(h in b)if(f=b[h],!A(f)&&!w(f)&&(g=h===c,!ga(g?a:a[h],f,d,c,g,g)))return!1;return!0}return d(a,b);case "function":return!1;default:return d(a,b)}}function Bc(a){return null===a?"null":typeof a}function Qd(a){var b=
+a.NUMBER_FORMATS;return function(a,c,e){w(c)&&(c=b.CURRENCY_SYM);w(e)&&(e=b.PATTERNS[1].maxFrac);return null==a?a:Ud(a,b.PATTERNS[1],b.GROUP_SEP,b.DECIMAL_SEP,e).replace(/\u00A4/g,c)}}function Sd(a){var b=a.NUMBER_FORMATS;return function(a,c){return null==a?a:Ud(a,b.PATTERNS[0],b.GROUP_SEP,b.DECIMAL_SEP,c)}}function Ng(a){var b=0,d,c,e,f,g;-1<(c=a.indexOf(Vd))&&(a=a.replace(Vd,""));0<(e=a.search(/e/i))?(0>c&&(c=e),c+=+a.slice(e+1),a=a.substring(0,e)):0>c&&(c=a.length);for(e=0;a.charAt(e)===Cc;e++);
+if(e===(g=a.length))d=[0],c=1;else{for(g--;a.charAt(g)===Cc;)g--;c-=e;d=[];for(f=0;e<=g;e++,f++)d[f]=+a.charAt(e)}c>Wd&&(d=d.splice(0,Wd-1),b=c-1,c=1);return{d:d,e:b,i:c}}function Og(a,b,d,c){var e=a.d,f=e.length-a.i;b=w(b)?Math.min(Math.max(d,f),c):+b;d=b+a.i;c=e[d];if(0<d){e.splice(Math.max(a.i,d));for(var g=d;g<e.length;g++)e[g]=0}else for(f=Math.max(0,f),a.i=1,e.length=Math.max(1,d=b+1),e[0]=0,g=1;g<d;g++)e[g]=0;if(5<=c)if(0>d-1){for(c=0;c>d;c--)e.unshift(0),a.i++;e.unshift(1);a.i++}else e[d-
+1]++;for(;f<Math.max(0,b);f++)e.push(0);if(b=e.reduceRight(function(a,b,c,d){b+=a;d[c]=b%10;return Math.floor(b/10)},0))e.unshift(b),a.i++}function Ud(a,b,d,c,e){if(!D(a)&&!Y(a)||isNaN(a))return"";var f=!isFinite(a),g=!1,k=Math.abs(a)+"",h="";if(f)h="\u221e";else{g=Ng(k);Og(g,e,b.minFrac,b.maxFrac);h=g.d;k=g.i;e=g.e;f=[];for(g=h.reduce(function(a,b){return a&&!b},!0);0>k;)h.unshift(0),k++;0<k?f=h.splice(k,h.length):(f=h,h=[0]);k=[];for(h.length>=b.lgSize&&k.unshift(h.splice(-b.lgSize,h.length).join(""));h.length>
+b.gSize;)k.unshift(h.splice(-b.gSize,h.length).join(""));h.length&&k.unshift(h.join(""));h=k.join(d);f.length&&(h+=c+f.join(""));e&&(h+="e+"+e)}return 0>a&&!g?b.negPre+h+b.negSuf:b.posPre+h+b.posSuf}function Mb(a,b,d,c){var e="";if(0>a||c&&0>=a)c?a=-a+1:(a=-a,e="-");for(a=""+a;a.length<b;)a=Cc+a;d&&(a=a.substr(a.length-b));return e+a}function da(a,b,d,c,e){d=d||0;return function(f){f=f["get"+a]();if(0<d||f>-d)f+=d;0===f&&-12===d&&(f=12);return Mb(f,b,c,e)}}function ob(a,b,d){return function(c,e){var f=
+c["get"+a](),g=wb((d?"STANDALONE":"")+(b?"SHORT":"")+a);return e[g][f]}}function Xd(a){var b=(new Date(a,0,1)).getDay();return new Date(a,0,(4>=b?5:12)-b)}function Yd(a){return function(b){var d=Xd(b.getFullYear());b=+new Date(b.getFullYear(),b.getMonth(),b.getDate()+(4-b.getDay()))-+d;b=1+Math.round(b/6048E5);return Mb(b,a)}}function Dc(a,b){return 0>=a.getFullYear()?b.ERAS[0]:b.ERAS[1]}function Rd(a){function b(a){var b;if(b=a.match(d)){a=new Date(0);var f=0,g=0,k=b[8]?a.setUTCFullYear:a.setFullYear,
+h=b[8]?a.setUTCHours:a.setHours;b[9]&&(f=Z(b[9]+b[10]),g=Z(b[9]+b[11]));k.call(a,Z(b[1]),Z(b[2])-1,Z(b[3]));f=Z(b[4]||0)-f;g=Z(b[5]||0)-g;k=Z(b[6]||0);b=Math.round(1E3*parseFloat("0."+(b[7]||0)));h.call(a,f,g,k,b)}return a}var d=/^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/;return function(c,d,f){var g="",k=[],h,l;d=d||"mediumDate";d=a.DATETIME_FORMATS[d]||d;D(c)&&(c=Pg.test(c)?Z(c):b(c));Y(c)&&(c=new Date(c));if(!ea(c)||!isFinite(c.getTime()))return c;
+for(;d;)(l=Qg.exec(d))?(k=eb(k,l,1),d=k.pop()):(k.push(d),d=null);var m=c.getTimezoneOffset();f&&(m=Rc(f,m),c=bc(c,f,!0));p(k,function(b){h=Rg[b];g+=h?h(c,a.DATETIME_FORMATS,m):"''"===b?"'":b.replace(/(^'|'$)/g,"").replace(/''/g,"'")});return g}}function Ig(){return function(a,b){w(b)&&(b=2);return fb(a,b)}}function Jg(){return function(a,b,d){b=Infinity===Math.abs(Number(b))?Number(b):Z(b);if(T(b))return a;Y(a)&&(a=a.toString());if(!xa(a))return a;d=!d||isNaN(d)?0:Z(d);d=0>d?Math.max(0,a.length+
+d):d;return 0<=b?Ec(a,d,d+b):0===d?Ec(a,b,a.length):Ec(a,Math.max(0,d+b),d)}}function Ec(a,b,d){return D(a)?a.slice(b,d):ya.call(a,b,d)}function Td(a){function b(b){return b.map(function(b){var c=1,d=bb;if(A(b))d=b;else if(D(b)){if("+"===b.charAt(0)||"-"===b.charAt(0))c="-"===b.charAt(0)?-1:1,b=b.substring(1);if(""!==b&&(d=a(b),d.constant))var e=d(),d=function(a){return a[e]}}return{get:d,descending:c}})}function d(a){switch(typeof a){case "number":case "boolean":case "string":return!0;default:return!1}}
+function c(a,b){var c=0,d=a.type,h=b.type;if(d===h){var h=a.value,l=b.value;"string"===d?(h=h.toLowerCase(),l=l.toLowerCase()):"object"===d&&(E(h)&&(h=a.index),E(l)&&(l=b.index));h!==l&&(c=h<l?-1:1)}else c=d<h?-1:1;return c}return function(a,f,g,k){if(null==a)return a;if(!xa(a))throw M("orderBy")("notarray",a);I(f)||(f=[f]);0===f.length&&(f=["+"]);var h=b(f),l=g?-1:1,m=A(k)?k:c;a=Array.prototype.map.call(a,function(a,b){return{value:a,tieBreaker:{value:b,type:"number",index:b},predicateValues:h.map(function(c){var e=
+c.get(a);c=typeof e;if(null===e)c="string",e="null";else if("object"===c)a:{if(A(e.valueOf)&&(e=e.valueOf(),d(e)))break a;Zb(e)&&(e=e.toString(),d(e))}return{value:e,type:c,index:b}})}});a.sort(function(a,b){for(var d=0,e=h.length;d<e;d++){var g=m(a.predicateValues[d],b.predicateValues[d]);if(g)return g*h[d].descending*l}return(m(a.tieBreaker,b.tieBreaker)||c(a.tieBreaker,b.tieBreaker))*l});return a=a.map(function(a){return a.value})}}function Qa(a){A(a)&&(a={link:a});a.restrict=a.restrict||"AC";
+return ka(a)}function Nb(a,b,d,c,e){this.$$controls=[];this.$error={};this.$$success={};this.$pending=void 0;this.$name=e(b.name||b.ngForm||"")(d);this.$dirty=!1;this.$valid=this.$pristine=!0;this.$submitted=this.$invalid=!1;this.$$parentForm=Ob;this.$$element=a;this.$$animate=c;Zd(this)}function Zd(a){a.$$classCache={};a.$$classCache[$d]=!(a.$$classCache[pb]=a.$$element.hasClass(pb))}function ae(a){function b(a,b,c){c&&!a.$$classCache[b]?(a.$$animate.addClass(a.$$element,b),a.$$classCache[b]=!0):
+!c&&a.$$classCache[b]&&(a.$$animate.removeClass(a.$$element,b),a.$$classCache[b]=!1)}function d(a,c,d){c=c?"-"+Vc(c,"-"):"";b(a,pb+c,!0===d);b(a,$d+c,!1===d)}var c=a.set,e=a.unset;a.clazz.prototype.$setValidity=function(a,g,k){w(g)?(this.$pending||(this.$pending={}),c(this.$pending,a,k)):(this.$pending&&e(this.$pending,a,k),be(this.$pending)&&(this.$pending=void 0));Na(g)?g?(e(this.$error,a,k),c(this.$$success,a,k)):(c(this.$error,a,k),e(this.$$success,a,k)):(e(this.$error,a,k),e(this.$$success,a,
+k));this.$pending?(b(this,"ng-pending",!0),this.$valid=this.$invalid=void 0,d(this,"",null)):(b(this,"ng-pending",!1),this.$valid=be(this.$error),this.$invalid=!this.$valid,d(this,"",this.$valid));g=this.$pending&&this.$pending[a]?void 0:this.$error[a]?!1:this.$$success[a]?!0:null;d(this,a,g);this.$$parentForm.$setValidity(a,g,this)}}function be(a){if(a)for(var b in a)if(a.hasOwnProperty(b))return!1;return!0}function Fc(a){a.$formatters.push(function(b){return a.$isEmpty(b)?b:b.toString()})}function Wa(a,
+b,d,c,e,f){var g=N(b[0].type);if(!e.android){var k=!1;b.on("compositionstart",function(){k=!0});b.on("compositionend",function(){k=!1;l()})}var h,l=function(a){h&&(f.defer.cancel(h),h=null);if(!k){var e=b.val();a=a&&a.type;"password"===g||d.ngTrim&&"false"===d.ngTrim||(e=Q(e));(c.$viewValue!==e||""===e&&c.$$hasNativeValidators)&&c.$setViewValue(e,a)}};if(e.hasEvent("input"))b.on("input",l);else{var m=function(a,b,c){h||(h=f.defer(function(){h=null;b&&b.value===c||l(a)}))};b.on("keydown",function(a){var b=
+a.keyCode;91===b||15<b&&19>b||37<=b&&40>=b||m(a,this,this.value)});if(e.hasEvent("paste"))b.on("paste cut",m)}b.on("change",l);if(ce[g]&&c.$$hasNativeValidators&&g===d.type)b.on("keydown wheel mousedown",function(a){if(!h){var b=this.validity,c=b.badInput,d=b.typeMismatch;h=f.defer(function(){h=null;b.badInput===c&&b.typeMismatch===d||l(a)})}});c.$render=function(){var a=c.$isEmpty(c.$viewValue)?"":c.$viewValue;b.val()!==a&&b.val(a)}}function Pb(a,b){return function(d,c){var e,f;if(ea(d))return d;
+if(D(d)){'"'===d.charAt(0)&&'"'===d.charAt(d.length-1)&&(d=d.substring(1,d.length-1));if(Sg.test(d))return new Date(d);a.lastIndex=0;if(e=a.exec(d))return e.shift(),f=c?{yyyy:c.getFullYear(),MM:c.getMonth()+1,dd:c.getDate(),HH:c.getHours(),mm:c.getMinutes(),ss:c.getSeconds(),sss:c.getMilliseconds()/1E3}:{yyyy:1970,MM:1,dd:1,HH:0,mm:0,ss:0,sss:0},p(e,function(a,c){c<b.length&&(f[b[c]]=+a)}),new Date(f.yyyy,f.MM-1,f.dd,f.HH,f.mm,f.ss||0,1E3*f.sss||0)}return NaN}}function qb(a,b,d,c){return function(e,
+f,g,k,h,l,m){function n(a){return a&&!(a.getTime&&a.getTime()!==a.getTime())}function q(a){return t(a)&&!ea(a)?d(a)||void 0:a}Gc(e,f,g,k);Wa(e,f,g,k,h,l);var p=k&&k.$options.getOption("timezone"),r;k.$$parserName=a;k.$parsers.push(function(a){if(k.$isEmpty(a))return null;if(b.test(a))return a=d(a,r),p&&(a=bc(a,p)),a});k.$formatters.push(function(a){if(a&&!ea(a))throw rb("datefmt",a);if(n(a))return(r=a)&&p&&(r=bc(r,p,!0)),m("date")(a,c,p);r=null;return""});if(t(g.min)||g.ngMin){var z;k.$validators.min=
+function(a){return!n(a)||w(z)||d(a)>=z};g.$observe("min",function(a){z=q(a);k.$validate()})}if(t(g.max)||g.ngMax){var v;k.$validators.max=function(a){return!n(a)||w(v)||d(a)<=v};g.$observe("max",function(a){v=q(a);k.$validate()})}}}function Gc(a,b,d,c){(c.$$hasNativeValidators=E(b[0].validity))&&c.$parsers.push(function(a){var c=b.prop("validity")||{};return c.badInput||c.typeMismatch?void 0:a})}function de(a){a.$$parserName="number";a.$parsers.push(function(b){if(a.$isEmpty(b))return null;if(Tg.test(b))return parseFloat(b)});
+a.$formatters.push(function(b){if(!a.$isEmpty(b)){if(!Y(b))throw rb("numfmt",b);b=b.toString()}return b})}function Xa(a){t(a)&&!Y(a)&&(a=parseFloat(a));return T(a)?void 0:a}function Hc(a){var b=a.toString(),d=b.indexOf(".");return-1===d?-1<a&&1>a&&(a=/e-(\d+)$/.exec(b))?Number(a[1]):0:b.length-d-1}function ee(a,b,d){a=Number(a);var c=(a|0)!==a,e=(b|0)!==b,f=(d|0)!==d;if(c||e||f){var g=c?Hc(a):0,k=e?Hc(b):0,h=f?Hc(d):0,g=Math.max(g,k,h),g=Math.pow(10,g);a*=g;b*=g;d*=g;c&&(a=Math.round(a));e&&(b=Math.round(b));
+f&&(d=Math.round(d))}return 0===(a-b)%d}function fe(a,b,d,c,e){if(t(c)){a=a(c);if(!a.constant)throw rb("constexpr",d,c);return a(b)}return e}function Ic(a,b){function d(a,b){if(!a||!a.length)return[];if(!b||!b.length)return a;var c=[],d=0;a:for(;d<a.length;d++){for(var e=a[d],f=0;f<b.length;f++)if(e===b[f])continue a;c.push(e)}return c}function c(a){var b=a;I(a)?b=a.map(c).join(" "):E(a)&&(b=Object.keys(a).filter(function(b){return a[b]}).join(" "));return b}function e(a){var b=a;if(I(a))b=a.map(e);
+else if(E(a)){var c=!1,b=Object.keys(a).filter(function(b){b=a[b];!c&&w(b)&&(c=!0);return b});c&&b.push(void 0)}return b}a="ngClass"+a;var f;return["$parse",function(g){return{restrict:"AC",link:function(k,h,l){function m(a,b){var c=[];p(a,function(a){if(0<b||s[a])s[a]=(s[a]||0)+b,s[a]===+(0<b)&&c.push(a)});return c.join(" ")}function n(a){if(a===b){var c=w,c=m(c&&c.split(" "),1);l.$addClass(c)}else c=w,c=m(c&&c.split(" "),-1),l.$removeClass(c);u=a}function q(a){a=c(a);a!==w&&r(a)}function r(a){if(u===
+b){var c=w&&w.split(" "),e=a&&a.split(" "),g=d(c,e),c=d(e,c),g=m(g,-1),c=m(c,1);l.$addClass(c);l.$removeClass(g)}w=a}var t=l[a].trim(),z=":"===t.charAt(0)&&":"===t.charAt(1),t=g(t,z?e:c),v=z?q:r,s=h.data("$classCounts"),u=!0,w;s||(s=S(),h.data("$classCounts",s));"ngClass"!==a&&(f||(f=g("$index",function(a){return a&1})),k.$watch(f,n));k.$watch(t,v,z)}}}]}function Qb(a,b,d,c,e,f,g,k,h){this.$modelValue=this.$viewValue=Number.NaN;this.$$rawModelValue=void 0;this.$validators={};this.$asyncValidators=
+{};this.$parsers=[];this.$formatters=[];this.$viewChangeListeners=[];this.$untouched=!0;this.$touched=!1;this.$pristine=!0;this.$dirty=!1;this.$valid=!0;this.$invalid=!1;this.$error={};this.$$success={};this.$pending=void 0;this.$name=h(d.name||"",!1)(a);this.$$parentForm=Ob;this.$options=Rb;this.$$parsedNgModel=e(d.ngModel);this.$$parsedNgModelAssign=this.$$parsedNgModel.assign;this.$$ngModelGet=this.$$parsedNgModel;this.$$ngModelSet=this.$$parsedNgModelAssign;this.$$pendingDebounce=null;this.$$parserValid=
+void 0;this.$$currentValidationRunId=0;Object.defineProperty(this,"$$scope",{value:a});this.$$attr=d;this.$$element=c;this.$$animate=f;this.$$timeout=g;this.$$parse=e;this.$$q=k;this.$$exceptionHandler=b;Zd(this);Ug(this)}function Ug(a){a.$$scope.$watch(function(b){b=a.$$ngModelGet(b);if(b!==a.$modelValue&&(a.$modelValue===a.$modelValue||b===b)){a.$modelValue=a.$$rawModelValue=b;a.$$parserValid=void 0;for(var d=a.$formatters,c=d.length,e=b;c--;)e=d[c](e);a.$viewValue!==e&&(a.$$updateEmptyClasses(e),
+a.$viewValue=a.$$lastCommittedViewValue=e,a.$render(),a.$$runValidators(a.$modelValue,a.$viewValue,C))}return b})}function Jc(a){this.$$options=a}function ge(a,b){p(b,function(b,c){t(a[c])||(a[c]=b)})}function Ga(a,b){a.prop("selected",b);a.attr("selected",b)}var Lc={objectMaxDepth:5},Vg=/^\/(.+)\/([a-z]*)$/,ra=Object.prototype.hasOwnProperty,N=function(a){return D(a)?a.toLowerCase():a},wb=function(a){return D(a)?a.toUpperCase():a},Ca,B,la,ya=[].slice,ug=[].splice,Wg=[].push,ha=Object.prototype.toString,
+Oc=Object.getPrototypeOf,qa=M("ng"),$=u.angular||(u.angular={}),ec,sb=0;Ca=u.document.documentMode;var T=Number.isNaN||function(a){return a!==a};C.$inject=[];bb.$inject=[];var I=Array.isArray,se=/^\[object (?:Uint8|Uint8Clamped|Uint16|Uint32|Int8|Int16|Int32|Float32|Float64)Array]$/,Q=function(a){return D(a)?a.trim():a},Md=function(a){return a.replace(/([-()[\]{}+?*.$^|,:#<!\\])/g,"\\$1").replace(/\x08/g,"\\x08")},Ja=function(){if(!t(Ja.rules)){var a=u.document.querySelector("[ng-csp]")||u.document.querySelector("[data-ng-csp]");
+if(a){var b=a.getAttribute("ng-csp")||a.getAttribute("data-ng-csp");Ja.rules={noUnsafeEval:!b||-1!==b.indexOf("no-unsafe-eval"),noInlineStyle:!b||-1!==b.indexOf("no-inline-style")}}else{a=Ja;try{new Function(""),b=!1}catch(d){b=!0}a.rules={noUnsafeEval:b,noInlineStyle:!1}}}return Ja.rules},tb=function(){if(t(tb.name_))return tb.name_;var a,b,d=Ha.length,c,e;for(b=0;b<d;++b)if(c=Ha[b],a=u.document.querySelector("["+c.replace(":","\\:")+"jq]")){e=a.getAttribute(c+"jq");break}return tb.name_=e},ue=/:/g,
+Ha=["ng-","data-ng-","ng:","x-ng-"],xe=function(a){var b=a.currentScript;if(!b)return!0;if(!(b instanceof u.HTMLScriptElement||b instanceof u.SVGScriptElement))return!1;b=b.attributes;return[b.getNamedItem("src"),b.getNamedItem("href"),b.getNamedItem("xlink:href")].every(function(b){if(!b)return!0;if(!b.value)return!1;var c=a.createElement("a");c.href=b.value;if(a.location.origin===c.origin)return!0;switch(c.protocol){case "http:":case "https:":case "ftp:":case "blob:":case "file:":case "data:":return!0;
+default:return!1}})}(u.document),Ae=/[A-Z]/g,Wc=!1,Oa=3,Fe={full:"1.6.6",major:1,minor:6,dot:6,codeName:"interdimensional-cable"};U.expando="ng339";var kb=U.cache={},gg=1;U._data=function(a){return this.cache[a[this.expando]]||{}};var cg=/-([a-z])/g,Xg=/^-ms-/,Bb={mouseleave:"mouseout",mouseenter:"mouseover"},hc=M("jqLite"),fg=/^<([\w-]+)\s*\/?>(?:<\/\1>|)$/,gc=/<|&#?\w+;/,dg=/<([\w:-]+)/,eg=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi,aa={option:[1,'<select multiple="multiple">',
+"</select>"],thead:[1,"<table>","</table>"],col:[2,"<table><colgroup>","</colgroup></table>"],tr:[2,"<table><tbody>","</tbody></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],_default:[0,"",""]};aa.optgroup=aa.option;aa.tbody=aa.tfoot=aa.colgroup=aa.caption=aa.thead;aa.th=aa.td;var lg=u.Node.prototype.contains||function(a){return!!(this.compareDocumentPosition(a)&16)},Sa=U.prototype={ready:gd,toString:function(){var a=[];p(this,function(b){a.push(""+b)});return"["+a.join(", ")+"]"},
+eq:function(a){return 0<=a?B(this[a]):B(this[this.length+a])},length:0,push:Wg,sort:[].sort,splice:[].splice},Hb={};p("multiple selected checked disabled readOnly required open".split(" "),function(a){Hb[N(a)]=a});var ld={};p("input select option textarea button form details".split(" "),function(a){ld[a]=!0});var sd={ngMinlength:"minlength",ngMaxlength:"maxlength",ngMin:"min",ngMax:"max",ngPattern:"pattern",ngStep:"step"};p({data:lc,removeData:kc,hasData:function(a){for(var b in kb[a.ng339])return!0;
+return!1},cleanData:function(a){for(var b=0,d=a.length;b<d;b++)kc(a[b])}},function(a,b){U[b]=a});p({data:lc,inheritedData:Fb,scope:function(a){return B.data(a,"$scope")||Fb(a.parentNode||a,["$isolateScope","$scope"])},isolateScope:function(a){return B.data(a,"$isolateScope")||B.data(a,"$isolateScopeNoTemplate")},controller:id,injector:function(a){return Fb(a,"$injector")},removeAttr:function(a,b){a.removeAttribute(b)},hasClass:Cb,css:function(a,b,d){b=yb(b.replace(Xg,"ms-"));if(t(d))a.style[b]=d;
+else return a.style[b]},attr:function(a,b,d){var c=a.nodeType;if(c!==Oa&&2!==c&&8!==c&&a.getAttribute){var c=N(b),e=Hb[c];if(t(d))null===d||!1===d&&e?a.removeAttribute(b):a.setAttribute(b,e?c:d);else return a=a.getAttribute(b),e&&null!==a&&(a=c),null===a?void 0:a}},prop:function(a,b,d){if(t(d))a[b]=d;else return a[b]},text:function(){function a(a,d){if(w(d)){var c=a.nodeType;return 1===c||c===Oa?a.textContent:""}a.textContent=d}a.$dv="";return a}(),val:function(a,b){if(w(b)){if(a.multiple&&"select"===
+za(a)){var d=[];p(a.options,function(a){a.selected&&d.push(a.value||a.text)});return d}return a.value}a.value=b},html:function(a,b){if(w(b))return a.innerHTML;zb(a,!0);a.innerHTML=b},empty:jd},function(a,b){U.prototype[b]=function(b,c){var e,f,g=this.length;if(a!==jd&&w(2===a.length&&a!==Cb&&a!==id?b:c)){if(E(b)){for(e=0;e<g;e++)if(a===lc)a(this[e],b);else for(f in b)a(this[e],f,b[f]);return this}e=a.$dv;g=w(e)?Math.min(g,1):g;for(f=0;f<g;f++){var k=a(this[f],b,c);e=e?e+k:k}return e}for(e=0;e<g;e++)a(this[e],
+b,c);return this}});p({removeData:kc,on:function(a,b,d,c){if(t(c))throw hc("onargs");if(fc(a)){c=Ab(a,!0);var e=c.events,f=c.handle;f||(f=c.handle=ig(a,e));c=0<=b.indexOf(" ")?b.split(" "):[b];for(var g=c.length,k=function(b,c,g){var k=e[b];k||(k=e[b]=[],k.specialHandlerWrapper=c,"$destroy"===b||g||a.addEventListener(b,f));k.push(d)};g--;)b=c[g],Bb[b]?(k(Bb[b],kg),k(b,void 0,!0)):k(b)}},off:hd,one:function(a,b,d){a=B(a);a.on(b,function e(){a.off(b,d);a.off(b,e)});a.on(b,d)},replaceWith:function(a,
+b){var d,c=a.parentNode;zb(a);p(new U(b),function(b){d?c.insertBefore(b,d.nextSibling):c.replaceChild(b,a);d=b})},children:function(a){var b=[];p(a.childNodes,function(a){1===a.nodeType&&b.push(a)});return b},contents:function(a){return a.contentDocument||a.childNodes||[]},append:function(a,b){var d=a.nodeType;if(1===d||11===d){b=new U(b);for(var d=0,c=b.length;d<c;d++)a.appendChild(b[d])}},prepend:function(a,b){if(1===a.nodeType){var d=a.firstChild;p(new U(b),function(b){a.insertBefore(b,d)})}},
+wrap:function(a,b){var d=B(b).eq(0).clone()[0],c=a.parentNode;c&&c.replaceChild(d,a);d.appendChild(a)},remove:Gb,detach:function(a){Gb(a,!0)},after:function(a,b){var d=a,c=a.parentNode;if(c){b=new U(b);for(var e=0,f=b.length;e<f;e++){var g=b[e];c.insertBefore(g,d.nextSibling);d=g}}},addClass:Eb,removeClass:Db,toggleClass:function(a,b,d){b&&p(b.split(" "),function(b){var e=d;w(e)&&(e=!Cb(a,b));(e?Eb:Db)(a,b)})},parent:function(a){return(a=a.parentNode)&&11!==a.nodeType?a:null},next:function(a){return a.nextElementSibling},
+find:function(a,b){return a.getElementsByTagName?a.getElementsByTagName(b):[]},clone:jc,triggerHandler:function(a,b,d){var c,e,f=b.type||b,g=Ab(a);if(g=(g=g&&g.events)&&g[f])c={preventDefault:function(){this.defaultPrevented=!0},isDefaultPrevented:function(){return!0===this.defaultPrevented},stopImmediatePropagation:function(){this.immediatePropagationStopped=!0},isImmediatePropagationStopped:function(){return!0===this.immediatePropagationStopped},stopPropagation:C,type:f,target:a},b.type&&(c=P(c,
+b)),b=ja(g),e=d?[c].concat(d):[c],p(b,function(b){c.isImmediatePropagationStopped()||b.apply(a,e)})}},function(a,b){U.prototype[b]=function(b,c,e){for(var f,g=0,k=this.length;g<k;g++)w(f)?(f=a(this[g],b,c,e),t(f)&&(f=B(f))):ic(f,a(this[g],b,c,e));return t(f)?f:this}});U.prototype.bind=U.prototype.on;U.prototype.unbind=U.prototype.off;var Yg=Object.create(null);md.prototype={_idx:function(a){if(a===this._lastKey)return this._lastIndex;this._lastKey=a;return this._lastIndex=this._keys.indexOf(a)},_transformKey:function(a){return T(a)?
+Yg:a},get:function(a){a=this._transformKey(a);a=this._idx(a);if(-1!==a)return this._values[a]},set:function(a,b){a=this._transformKey(a);var d=this._idx(a);-1===d&&(d=this._lastIndex=this._keys.length);this._keys[d]=a;this._values[d]=b},delete:function(a){a=this._transformKey(a);a=this._idx(a);if(-1===a)return!1;this._keys.splice(a,1);this._values.splice(a,1);this._lastKey=NaN;this._lastIndex=-1;return!0}};var Ib=md,ag=[function(){this.$get=[function(){return Ib}]}],ng=/^([^(]+?)=>/,og=/^[^(]*\(\s*([^)]*)\)/m,
+Zg=/,/,$g=/^\s*(_?)(\S+?)\1\s*$/,mg=/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg,Ba=M("$injector");hb.$$annotate=function(a,b,d){var c;if("function"===typeof a){if(!(c=a.$inject)){c=[];if(a.length){if(b)throw D(d)&&d||(d=a.name||pg(a)),Ba("strictdi",d);b=nd(a);p(b[1].split(Zg),function(a){a.replace($g,function(a,b,d){c.push(d)})})}a.$inject=c}}else I(a)?(b=a.length-1,ub(a[b],"fn"),c=a.slice(0,b)):ub(a,"fn",!0);return c};var he=M("$animate"),sf=function(){this.$get=C},tf=function(){var a=new Ib,b=[];this.$get=
+["$$AnimateRunner","$rootScope",function(d,c){function e(a,b,c){var d=!1;b&&(b=D(b)?b.split(" "):I(b)?b:[],p(b,function(b){b&&(d=!0,a[b]=c)}));return d}function f(){p(b,function(b){var c=a.get(b);if(c){var d=qg(b.attr("class")),e="",f="";p(c,function(a,b){a!==!!d[b]&&(a?e+=(e.length?" ":"")+b:f+=(f.length?" ":"")+b)});p(b,function(a){e&&Eb(a,e);f&&Db(a,f)});a.delete(b)}});b.length=0}return{enabled:C,on:C,off:C,pin:C,push:function(g,k,h,l){l&&l();h=h||{};h.from&&g.css(h.from);h.to&&g.css(h.to);if(h.addClass||
+h.removeClass)if(k=h.addClass,l=h.removeClass,h=a.get(g)||{},k=e(h,k,!0),l=e(h,l,!1),k||l)a.set(g,h),b.push(g),1===b.length&&c.$$postDigest(f);g=new d;g.complete();return g}}}]},qf=["$provide",function(a){var b=this,d=null,c=null;this.$$registeredAnimations=Object.create(null);this.register=function(c,d){if(c&&"."!==c.charAt(0))throw he("notcsel",c);var g=c+"-animation";b.$$registeredAnimations[c.substr(1)]=g;a.factory(g,d)};this.customFilter=function(a){1===arguments.length&&(c=A(a)?a:null);return c};
+this.classNameFilter=function(a){if(1===arguments.length&&(d=a instanceof RegExp?a:null)&&/[(\s|\/)]ng-animate[(\s|\/)]/.test(d.toString()))throw d=null,he("nongcls","ng-animate");return d};this.$get=["$$animateQueue",function(a){function b(a,c,d){if(d){var e;a:{for(e=0;e<d.length;e++){var f=d[e];if(1===f.nodeType){e=f;break a}}e=void 0}!e||e.parentNode||e.previousElementSibling||(d=null)}d?d.after(a):c.prepend(a)}return{on:a.on,off:a.off,pin:a.pin,enabled:a.enabled,cancel:function(a){a.end&&a.end()},
+enter:function(c,d,h,l){d=d&&B(d);h=h&&B(h);d=d||h.parent();b(c,d,h);return a.push(c,"enter",Ka(l))},move:function(c,d,h,l){d=d&&B(d);h=h&&B(h);d=d||h.parent();b(c,d,h);return a.push(c,"move",Ka(l))},leave:function(b,c){return a.push(b,"leave",Ka(c),function(){b.remove()})},addClass:function(b,c,d){d=Ka(d);d.addClass=lb(d.addclass,c);return a.push(b,"addClass",d)},removeClass:function(b,c,d){d=Ka(d);d.removeClass=lb(d.removeClass,c);return a.push(b,"removeClass",d)},setClass:function(b,c,d,f){f=Ka(f);
+f.addClass=lb(f.addClass,c);f.removeClass=lb(f.removeClass,d);return a.push(b,"setClass",f)},animate:function(b,c,d,f,m){m=Ka(m);m.from=m.from?P(m.from,c):c;m.to=m.to?P(m.to,d):d;m.tempClasses=lb(m.tempClasses,f||"ng-inline-animate");return a.push(b,"animate",m)}}}]}],vf=function(){this.$get=["$$rAF",function(a){function b(b){d.push(b);1<d.length||a(function(){for(var a=0;a<d.length;a++)d[a]();d=[]})}var d=[];return function(){var a=!1;b(function(){a=!0});return function(d){a?d():b(d)}}}]},uf=function(){this.$get=
+["$q","$sniffer","$$animateAsyncRun","$$isDocumentHidden","$timeout",function(a,b,d,c,e){function f(a){this.setHost(a);var b=d();this._doneCallbacks=[];this._tick=function(a){c()?e(a,0,!1):b(a)};this._state=0}f.chain=function(a,b){function c(){if(d===a.length)b(!0);else a[d](function(a){!1===a?b(!1):(d++,c())})}var d=0;c()};f.all=function(a,b){function c(f){e=e&&f;++d===a.length&&b(e)}var d=0,e=!0;p(a,function(a){a.done(c)})};f.prototype={setHost:function(a){this.host=a||{}},done:function(a){2===
+this._state?a():this._doneCallbacks.push(a)},progress:C,getPromise:function(){if(!this.promise){var b=this;this.promise=a(function(a,c){b.done(function(b){!1===b?c():a()})})}return this.promise},then:function(a,b){return this.getPromise().then(a,b)},"catch":function(a){return this.getPromise()["catch"](a)},"finally":function(a){return this.getPromise()["finally"](a)},pause:function(){this.host.pause&&this.host.pause()},resume:function(){this.host.resume&&this.host.resume()},end:function(){this.host.end&&
+this.host.end();this._resolve(!0)},cancel:function(){this.host.cancel&&this.host.cancel();this._resolve(!1)},complete:function(a){var b=this;0===b._state&&(b._state=1,b._tick(function(){b._resolve(a)}))},_resolve:function(a){2!==this._state&&(p(this._doneCallbacks,function(b){b(a)}),this._doneCallbacks.length=0,this._state=2)}};return f}]},rf=function(){this.$get=["$$rAF","$q","$$AnimateRunner",function(a,b,d){return function(b,e){function f(){a(function(){g.addClass&&(b.addClass(g.addClass),g.addClass=
+null);g.removeClass&&(b.removeClass(g.removeClass),g.removeClass=null);g.to&&(b.css(g.to),g.to=null);k||h.complete();k=!0});return h}var g=e||{};g.$$prepared||(g=pa(g));g.cleanupStyles&&(g.from=g.to=null);g.from&&(b.css(g.from),g.from=null);var k,h=new d;return{start:f,end:f}}}]},ba=M("$compile"),oc=new function(){};Yc.$inject=["$provide","$$sanitizeUriProvider"];Kb.prototype.isFirstChange=function(){return this.previousValue===oc};var od=/^((?:x|data)[:\-_])/i,tg=/[:\-_]+(.)/g,ud=M("$controller"),
+td=/^(\S+)(\s+as\s+([\w$]+))?$/,Cf=function(){this.$get=["$document",function(a){return function(b){b?!b.nodeType&&b instanceof B&&(b=b[0]):b=a[0].body;return b.offsetWidth+1}}]},vd="application/json",sc={"Content-Type":vd+";charset=utf-8"},wg=/^\[|^\{(?!\{)/,xg={"[":/]$/,"{":/}$/},vg=/^\)]\}',?\n/,rc=M("$http"),Fa=$.$interpolateMinErr=M("$interpolate");Fa.throwNoconcat=function(a){throw Fa("noconcat",a);};Fa.interr=function(a,b){return Fa("interr",a,b.toString())};var Kf=function(){this.$get=function(){function a(a){var b=
+function(a){b.data=a;b.called=!0};b.id=a;return b}var b=$.callbacks,d={};return{createCallback:function(c){c="_"+(b.$$counter++).toString(36);var e="angular.callbacks."+c,f=a(c);d[e]=b[c]=f;return e},wasCalled:function(a){return d[a].called},getResponse:function(a){return d[a].data},removeCallback:function(a){delete b[d[a].id];delete d[a]}}}},ah=/^([^?#]*)(\?([^#]*))?(#(.*))?$/,zg={http:80,https:443,ftp:21},mb=M("$location"),Ag=/^\s*[\\/]{2,}/,bh={$$absUrl:"",$$html5:!1,$$replace:!1,absUrl:Lb("$$absUrl"),
+url:function(a){if(w(a))return this.$$url;var b=ah.exec(a);(b[1]||""===a)&&this.path(decodeURIComponent(b[1]));(b[2]||b[1]||""===a)&&this.search(b[3]||"");this.hash(b[5]||"");return this},protocol:Lb("$$protocol"),host:Lb("$$host"),port:Lb("$$port"),path:Dd("$$path",function(a){a=null!==a?a.toString():"";return"/"===a.charAt(0)?a:"/"+a}),search:function(a,b){switch(arguments.length){case 0:return this.$$search;case 1:if(D(a)||Y(a))a=a.toString(),this.$$search=Tc(a);else if(E(a))a=pa(a,{}),p(a,function(b,
+c){null==b&&delete a[c]}),this.$$search=a;else throw mb("isrcharg");break;default:w(b)||null===b?delete this.$$search[a]:this.$$search[a]=b}this.$$compose();return this},hash:Dd("$$hash",function(a){return null!==a?a.toString():""}),replace:function(){this.$$replace=!0;return this}};p([Cd,wc,vc],function(a){a.prototype=Object.create(bh);a.prototype.state=function(b){if(!arguments.length)return this.$$state;if(a!==vc||!this.$$html5)throw mb("nostate");this.$$state=w(b)?null:b;this.$$urlUpdatedByLocation=
+!0;return this}});var Ya=M("$parse"),Eg={}.constructor.prototype.valueOf,Sb=S();p("+ - * / % === !== == != < > <= >= && || ! = |".split(" "),function(a){Sb[a]=!0});var ch={n:"\n",f:"\f",r:"\r",t:"\t",v:"\v","'":"'",'"':'"'},zc=function(a){this.options=a};zc.prototype={constructor:zc,lex:function(a){this.text=a;this.index=0;for(this.tokens=[];this.index<this.text.length;)if(a=this.text.charAt(this.index),'"'===a||"'"===a)this.readString(a);else if(this.isNumber(a)||"."===a&&this.isNumber(this.peek()))this.readNumber();
+else if(this.isIdentifierStart(this.peekMultichar()))this.readIdent();else if(this.is(a,"(){}[].,;:?"))this.tokens.push({index:this.index,text:a}),this.index++;else if(this.isWhitespace(a))this.index++;else{var b=a+this.peek(),d=b+this.peek(2),c=Sb[b],e=Sb[d];Sb[a]||c||e?(a=e?d:c?b:a,this.tokens.push({index:this.index,text:a,operator:!0}),this.index+=a.length):this.throwError("Unexpected next character ",this.index,this.index+1)}return this.tokens},is:function(a,b){return-1!==b.indexOf(a)},peek:function(a){a=
+a||1;return this.index+a<this.text.length?this.text.charAt(this.index+a):!1},isNumber:function(a){return"0"<=a&&"9">=a&&"string"===typeof a},isWhitespace:function(a){return" "===a||"\r"===a||"\t"===a||"\n"===a||"\v"===a||"\u00a0"===a},isIdentifierStart:function(a){return this.options.isIdentifierStart?this.options.isIdentifierStart(a,this.codePointAt(a)):this.isValidIdentifierStart(a)},isValidIdentifierStart:function(a){return"a"<=a&&"z">=a||"A"<=a&&"Z">=a||"_"===a||"$"===a},isIdentifierContinue:function(a){return this.options.isIdentifierContinue?
+this.options.isIdentifierContinue(a,this.codePointAt(a)):this.isValidIdentifierContinue(a)},isValidIdentifierContinue:function(a,b){return this.isValidIdentifierStart(a,b)||this.isNumber(a)},codePointAt:function(a){return 1===a.length?a.charCodeAt(0):(a.charCodeAt(0)<<10)+a.charCodeAt(1)-56613888},peekMultichar:function(){var a=this.text.charAt(this.index),b=this.peek();if(!b)return a;var d=a.charCodeAt(0),c=b.charCodeAt(0);return 55296<=d&&56319>=d&&56320<=c&&57343>=c?a+b:a},isExpOperator:function(a){return"-"===
+a||"+"===a||this.isNumber(a)},throwError:function(a,b,d){d=d||this.index;b=t(b)?"s "+b+"-"+this.index+" ["+this.text.substring(b,d)+"]":" "+d;throw Ya("lexerr",a,b,this.text);},readNumber:function(){for(var a="",b=this.index;this.index<this.text.length;){var d=N(this.text.charAt(this.index));if("."===d||this.isNumber(d))a+=d;else{var c=this.peek();if("e"===d&&this.isExpOperator(c))a+=d;else if(this.isExpOperator(d)&&c&&this.isNumber(c)&&"e"===a.charAt(a.length-1))a+=d;else if(!this.isExpOperator(d)||
+c&&this.isNumber(c)||"e"!==a.charAt(a.length-1))break;else this.throwError("Invalid exponent")}this.index++}this.tokens.push({index:b,text:a,constant:!0,value:Number(a)})},readIdent:function(){var a=this.index;for(this.index+=this.peekMultichar().length;this.index<this.text.length;){var b=this.peekMultichar();if(!this.isIdentifierContinue(b))break;this.index+=b.length}this.tokens.push({index:a,text:this.text.slice(a,this.index),identifier:!0})},readString:function(a){var b=this.index;this.index++;
+for(var d="",c=a,e=!1;this.index<this.text.length;){var f=this.text.charAt(this.index),c=c+f;if(e)"u"===f?(e=this.text.substring(this.index+1,this.index+5),e.match(/[\da-f]{4}/i)||this.throwError("Invalid unicode escape [\\u"+e+"]"),this.index+=4,d+=String.fromCharCode(parseInt(e,16))):d+=ch[f]||f,e=!1;else if("\\"===f)e=!0;else{if(f===a){this.index++;this.tokens.push({index:b,text:c,constant:!0,value:d});return}d+=f}this.index++}this.throwError("Unterminated quote",b)}};var r=function(a,b){this.lexer=
+a;this.options=b};r.Program="Program";r.ExpressionStatement="ExpressionStatement";r.AssignmentExpression="AssignmentExpression";r.ConditionalExpression="ConditionalExpression";r.LogicalExpression="LogicalExpression";r.BinaryExpression="BinaryExpression";r.UnaryExpression="UnaryExpression";r.CallExpression="CallExpression";r.MemberExpression="MemberExpression";r.Identifier="Identifier";r.Literal="Literal";r.ArrayExpression="ArrayExpression";r.Property="Property";r.ObjectExpression="ObjectExpression";
+r.ThisExpression="ThisExpression";r.LocalsExpression="LocalsExpression";r.NGValueParameter="NGValueParameter";r.prototype={ast:function(a){this.text=a;this.tokens=this.lexer.lex(a);a=this.program();0!==this.tokens.length&&this.throwError("is an unexpected token",this.tokens[0]);return a},program:function(){for(var a=[];;)if(0<this.tokens.length&&!this.peek("}",")",";","]")&&a.push(this.expressionStatement()),!this.expect(";"))return{type:r.Program,body:a}},expressionStatement:function(){return{type:r.ExpressionStatement,
+expression:this.filterChain()}},filterChain:function(){for(var a=this.expression();this.expect("|");)a=this.filter(a);return a},expression:function(){return this.assignment()},assignment:function(){var a=this.ternary();if(this.expect("=")){if(!Hd(a))throw Ya("lval");a={type:r.AssignmentExpression,left:a,right:this.assignment(),operator:"="}}return a},ternary:function(){var a=this.logicalOR(),b,d;return this.expect("?")&&(b=this.expression(),this.consume(":"))?(d=this.expression(),{type:r.ConditionalExpression,
+test:a,alternate:b,consequent:d}):a},logicalOR:function(){for(var a=this.logicalAND();this.expect("||");)a={type:r.LogicalExpression,operator:"||",left:a,right:this.logicalAND()};return a},logicalAND:function(){for(var a=this.equality();this.expect("&&");)a={type:r.LogicalExpression,operator:"&&",left:a,right:this.equality()};return a},equality:function(){for(var a=this.relational(),b;b=this.expect("==","!=","===","!==");)a={type:r.BinaryExpression,operator:b.text,left:a,right:this.relational()};
+return a},relational:function(){for(var a=this.additive(),b;b=this.expect("<",">","<=",">=");)a={type:r.BinaryExpression,operator:b.text,left:a,right:this.additive()};return a},additive:function(){for(var a=this.multiplicative(),b;b=this.expect("+","-");)a={type:r.BinaryExpression,operator:b.text,left:a,right:this.multiplicative()};return a},multiplicative:function(){for(var a=this.unary(),b;b=this.expect("*","/","%");)a={type:r.BinaryExpression,operator:b.text,left:a,right:this.unary()};return a},
+unary:function(){var a;return(a=this.expect("+","-","!"))?{type:r.UnaryExpression,operator:a.text,prefix:!0,argument:this.unary()}:this.primary()},primary:function(){var a;this.expect("(")?(a=this.filterChain(),this.consume(")")):this.expect("[")?a=this.arrayDeclaration():this.expect("{")?a=this.object():this.selfReferential.hasOwnProperty(this.peek().text)?a=pa(this.selfReferential[this.consume().text]):this.options.literals.hasOwnProperty(this.peek().text)?a={type:r.Literal,value:this.options.literals[this.consume().text]}:
+this.peek().identifier?a=this.identifier():this.peek().constant?a=this.constant():this.throwError("not a primary expression",this.peek());for(var b;b=this.expect("(","[",".");)"("===b.text?(a={type:r.CallExpression,callee:a,arguments:this.parseArguments()},this.consume(")")):"["===b.text?(a={type:r.MemberExpression,object:a,property:this.expression(),computed:!0},this.consume("]")):"."===b.text?a={type:r.MemberExpression,object:a,property:this.identifier(),computed:!1}:this.throwError("IMPOSSIBLE");
+return a},filter:function(a){a=[a];for(var b={type:r.CallExpression,callee:this.identifier(),arguments:a,filter:!0};this.expect(":");)a.push(this.expression());return b},parseArguments:function(){var a=[];if(")"!==this.peekToken().text){do a.push(this.filterChain());while(this.expect(","))}return a},identifier:function(){var a=this.consume();a.identifier||this.throwError("is not a valid identifier",a);return{type:r.Identifier,name:a.text}},constant:function(){return{type:r.Literal,value:this.consume().value}},
+arrayDeclaration:function(){var a=[];if("]"!==this.peekToken().text){do{if(this.peek("]"))break;a.push(this.expression())}while(this.expect(","))}this.consume("]");return{type:r.ArrayExpression,elements:a}},object:function(){var a=[],b;if("}"!==this.peekToken().text){do{if(this.peek("}"))break;b={type:r.Property,kind:"init"};this.peek().constant?(b.key=this.constant(),b.computed=!1,this.consume(":"),b.value=this.expression()):this.peek().identifier?(b.key=this.identifier(),b.computed=!1,this.peek(":")?
+(this.consume(":"),b.value=this.expression()):b.value=b.key):this.peek("[")?(this.consume("["),b.key=this.expression(),this.consume("]"),b.computed=!0,this.consume(":"),b.value=this.expression()):this.throwError("invalid key",this.peek());a.push(b)}while(this.expect(","))}this.consume("}");return{type:r.ObjectExpression,properties:a}},throwError:function(a,b){throw Ya("syntax",b.text,a,b.index+1,this.text,this.text.substring(b.index));},consume:function(a){if(0===this.tokens.length)throw Ya("ueoe",
+this.text);var b=this.expect(a);b||this.throwError("is unexpected, expecting ["+a+"]",this.peek());return b},peekToken:function(){if(0===this.tokens.length)throw Ya("ueoe",this.text);return this.tokens[0]},peek:function(a,b,d,c){return this.peekAhead(0,a,b,d,c)},peekAhead:function(a,b,d,c,e){if(this.tokens.length>a){a=this.tokens[a];var f=a.text;if(f===b||f===d||f===c||f===e||!(b||d||c||e))return a}return!1},expect:function(a,b,d,c){return(a=this.peek(a,b,d,c))?(this.tokens.shift(),a):!1},selfReferential:{"this":{type:r.ThisExpression},
+$locals:{type:r.LocalsExpression}}};var Fd=2;Jd.prototype={compile:function(a){var b=this;this.state={nextId:0,filters:{},fn:{vars:[],body:[],own:{}},assign:{vars:[],body:[],own:{}},inputs:[]};V(a,b.$filter);var d="",c;this.stage="assign";if(c=Id(a))this.state.computing="assign",d=this.nextId(),this.recurse(c,d),this.return_(d),d="fn.assign="+this.generateFunction("assign","s,v,l");c=Gd(a.body);b.stage="inputs";p(c,function(a,c){var d="fn"+c;b.state[d]={vars:[],body:[],own:{}};b.state.computing=d;
+var k=b.nextId();b.recurse(a,k);b.return_(k);b.state.inputs.push({name:d,isPure:a.isPure});a.watchId=c});this.state.computing="fn";this.stage="main";this.recurse(a);a='"'+this.USE+" "+this.STRICT+'";\n'+this.filterPrefix()+"var fn="+this.generateFunction("fn","s,l,a,i")+d+this.watchFns()+"return fn;";a=(new Function("$filter","getStringValue","ifDefined","plus",a))(this.$filter,Bg,Cg,Ed);this.state=this.stage=void 0;return a},USE:"use",STRICT:"strict",watchFns:function(){var a=[],b=this.state.inputs,
+d=this;p(b,function(b){a.push("var "+b.name+"="+d.generateFunction(b.name,"s"));b.isPure&&a.push(b.name,".isPure="+JSON.stringify(b.isPure)+";")});b.length&&a.push("fn.inputs=["+b.map(function(a){return a.name}).join(",")+"];");return a.join("")},generateFunction:function(a,b){return"function("+b+"){"+this.varsPrefix(a)+this.body(a)+"};"},filterPrefix:function(){var a=[],b=this;p(this.state.filters,function(d,c){a.push(d+"=$filter("+b.escape(c)+")")});return a.length?"var "+a.join(",")+";":""},varsPrefix:function(a){return this.state[a].vars.length?
+"var "+this.state[a].vars.join(",")+";":""},body:function(a){return this.state[a].body.join("")},recurse:function(a,b,d,c,e,f){var g,k,h=this,l,m,n;c=c||C;if(!f&&t(a.watchId))b=b||this.nextId(),this.if_("i",this.lazyAssign(b,this.computedMember("i",a.watchId)),this.lazyRecurse(a,b,d,c,e,!0));else switch(a.type){case r.Program:p(a.body,function(b,c){h.recurse(b.expression,void 0,void 0,function(a){k=a});c!==a.body.length-1?h.current().body.push(k,";"):h.return_(k)});break;case r.Literal:m=this.escape(a.value);
+this.assign(b,m);c(b||m);break;case r.UnaryExpression:this.recurse(a.argument,void 0,void 0,function(a){k=a});m=a.operator+"("+this.ifDefined(k,0)+")";this.assign(b,m);c(m);break;case r.BinaryExpression:this.recurse(a.left,void 0,void 0,function(a){g=a});this.recurse(a.right,void 0,void 0,function(a){k=a});m="+"===a.operator?this.plus(g,k):"-"===a.operator?this.ifDefined(g,0)+a.operator+this.ifDefined(k,0):"("+g+")"+a.operator+"("+k+")";this.assign(b,m);c(m);break;case r.LogicalExpression:b=b||this.nextId();
+h.recurse(a.left,b);h.if_("&&"===a.operator?b:h.not(b),h.lazyRecurse(a.right,b));c(b);break;case r.ConditionalExpression:b=b||this.nextId();h.recurse(a.test,b);h.if_(b,h.lazyRecurse(a.alternate,b),h.lazyRecurse(a.consequent,b));c(b);break;case r.Identifier:b=b||this.nextId();d&&(d.context="inputs"===h.stage?"s":this.assign(this.nextId(),this.getHasOwnProperty("l",a.name)+"?l:s"),d.computed=!1,d.name=a.name);h.if_("inputs"===h.stage||h.not(h.getHasOwnProperty("l",a.name)),function(){h.if_("inputs"===
+h.stage||"s",function(){e&&1!==e&&h.if_(h.isNull(h.nonComputedMember("s",a.name)),h.lazyAssign(h.nonComputedMember("s",a.name),"{}"));h.assign(b,h.nonComputedMember("s",a.name))})},b&&h.lazyAssign(b,h.nonComputedMember("l",a.name)));c(b);break;case r.MemberExpression:g=d&&(d.context=this.nextId())||this.nextId();b=b||this.nextId();h.recurse(a.object,g,void 0,function(){h.if_(h.notNull(g),function(){a.computed?(k=h.nextId(),h.recurse(a.property,k),h.getStringValue(k),e&&1!==e&&h.if_(h.not(h.computedMember(g,
+k)),h.lazyAssign(h.computedMember(g,k),"{}")),m=h.computedMember(g,k),h.assign(b,m),d&&(d.computed=!0,d.name=k)):(e&&1!==e&&h.if_(h.isNull(h.nonComputedMember(g,a.property.name)),h.lazyAssign(h.nonComputedMember(g,a.property.name),"{}")),m=h.nonComputedMember(g,a.property.name),h.assign(b,m),d&&(d.computed=!1,d.name=a.property.name))},function(){h.assign(b,"undefined")});c(b)},!!e);break;case r.CallExpression:b=b||this.nextId();a.filter?(k=h.filter(a.callee.name),l=[],p(a.arguments,function(a){var b=
+h.nextId();h.recurse(a,b);l.push(b)}),m=k+"("+l.join(",")+")",h.assign(b,m),c(b)):(k=h.nextId(),g={},l=[],h.recurse(a.callee,k,g,function(){h.if_(h.notNull(k),function(){p(a.arguments,function(b){h.recurse(b,a.constant?void 0:h.nextId(),void 0,function(a){l.push(a)})});m=g.name?h.member(g.context,g.name,g.computed)+"("+l.join(",")+")":k+"("+l.join(",")+")";h.assign(b,m)},function(){h.assign(b,"undefined")});c(b)}));break;case r.AssignmentExpression:k=this.nextId();g={};this.recurse(a.left,void 0,
+g,function(){h.if_(h.notNull(g.context),function(){h.recurse(a.right,k);m=h.member(g.context,g.name,g.computed)+a.operator+k;h.assign(b,m);c(b||m)})},1);break;case r.ArrayExpression:l=[];p(a.elements,function(b){h.recurse(b,a.constant?void 0:h.nextId(),void 0,function(a){l.push(a)})});m="["+l.join(",")+"]";this.assign(b,m);c(b||m);break;case r.ObjectExpression:l=[];n=!1;p(a.properties,function(a){a.computed&&(n=!0)});n?(b=b||this.nextId(),this.assign(b,"{}"),p(a.properties,function(a){a.computed?
+(g=h.nextId(),h.recurse(a.key,g)):g=a.key.type===r.Identifier?a.key.name:""+a.key.value;k=h.nextId();h.recurse(a.value,k);h.assign(h.member(b,g,a.computed),k)})):(p(a.properties,function(b){h.recurse(b.value,a.constant?void 0:h.nextId(),void 0,function(a){l.push(h.escape(b.key.type===r.Identifier?b.key.name:""+b.key.value)+":"+a)})}),m="{"+l.join(",")+"}",this.assign(b,m));c(b||m);break;case r.ThisExpression:this.assign(b,"s");c(b||"s");break;case r.LocalsExpression:this.assign(b,"l");c(b||"l");break;
+case r.NGValueParameter:this.assign(b,"v"),c(b||"v")}},getHasOwnProperty:function(a,b){var d=a+"."+b,c=this.current().own;c.hasOwnProperty(d)||(c[d]=this.nextId(!1,a+"&&("+this.escape(b)+" in "+a+")"));return c[d]},assign:function(a,b){if(a)return this.current().body.push(a,"=",b,";"),a},filter:function(a){this.state.filters.hasOwnProperty(a)||(this.state.filters[a]=this.nextId(!0));return this.state.filters[a]},ifDefined:function(a,b){return"ifDefined("+a+","+this.escape(b)+")"},plus:function(a,
+b){return"plus("+a+","+b+")"},return_:function(a){this.current().body.push("return ",a,";")},if_:function(a,b,d){if(!0===a)b();else{var c=this.current().body;c.push("if(",a,"){");b();c.push("}");d&&(c.push("else{"),d(),c.push("}"))}},not:function(a){return"!("+a+")"},isNull:function(a){return a+"==null"},notNull:function(a){return a+"!=null"},nonComputedMember:function(a,b){var d=/[^$_a-zA-Z0-9]/g;return/^[$_a-zA-Z][$_a-zA-Z0-9]*$/.test(b)?a+"."+b:a+'["'+b.replace(d,this.stringEscapeFn)+'"]'},computedMember:function(a,
+b){return a+"["+b+"]"},member:function(a,b,d){return d?this.computedMember(a,b):this.nonComputedMember(a,b)},getStringValue:function(a){this.assign(a,"getStringValue("+a+")")},lazyRecurse:function(a,b,d,c,e,f){var g=this;return function(){g.recurse(a,b,d,c,e,f)}},lazyAssign:function(a,b){var d=this;return function(){d.assign(a,b)}},stringEscapeRegex:/[^ a-zA-Z0-9]/g,stringEscapeFn:function(a){return"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)},escape:function(a){if(D(a))return"'"+a.replace(this.stringEscapeRegex,
+this.stringEscapeFn)+"'";if(Y(a))return a.toString();if(!0===a)return"true";if(!1===a)return"false";if(null===a)return"null";if("undefined"===typeof a)return"undefined";throw Ya("esc");},nextId:function(a,b){var d="v"+this.state.nextId++;a||this.current().vars.push(d+(b?"="+b:""));return d},current:function(){return this.state[this.state.computing]}};Kd.prototype={compile:function(a){var b=this;V(a,b.$filter);var d,c;if(d=Id(a))c=this.recurse(d);d=Gd(a.body);var e;d&&(e=[],p(d,function(a,c){var d=
+b.recurse(a);d.isPure=a.isPure;a.input=d;e.push(d);a.watchId=c}));var f=[];p(a.body,function(a){f.push(b.recurse(a.expression))});a=0===a.body.length?C:1===a.body.length?f[0]:function(a,b){var c;p(f,function(d){c=d(a,b)});return c};c&&(a.assign=function(a,b,d){return c(a,d,b)});e&&(a.inputs=e);return a},recurse:function(a,b,d){var c,e,f=this,g;if(a.input)return this.inputs(a.input,a.watchId);switch(a.type){case r.Literal:return this.value(a.value,b);case r.UnaryExpression:return e=this.recurse(a.argument),
+this["unary"+a.operator](e,b);case r.BinaryExpression:return c=this.recurse(a.left),e=this.recurse(a.right),this["binary"+a.operator](c,e,b);case r.LogicalExpression:return c=this.recurse(a.left),e=this.recurse(a.right),this["binary"+a.operator](c,e,b);case r.ConditionalExpression:return this["ternary?:"](this.recurse(a.test),this.recurse(a.alternate),this.recurse(a.consequent),b);case r.Identifier:return f.identifier(a.name,b,d);case r.MemberExpression:return c=this.recurse(a.object,!1,!!d),a.computed||
+(e=a.property.name),a.computed&&(e=this.recurse(a.property)),a.computed?this.computedMember(c,e,b,d):this.nonComputedMember(c,e,b,d);case r.CallExpression:return g=[],p(a.arguments,function(a){g.push(f.recurse(a))}),a.filter&&(e=this.$filter(a.callee.name)),a.filter||(e=this.recurse(a.callee,!0)),a.filter?function(a,c,d,f){for(var n=[],q=0;q<g.length;++q)n.push(g[q](a,c,d,f));a=e.apply(void 0,n,f);return b?{context:void 0,name:void 0,value:a}:a}:function(a,c,d,f){var n=e(a,c,d,f),q;if(null!=n.value){q=
+[];for(var p=0;p<g.length;++p)q.push(g[p](a,c,d,f));q=n.value.apply(n.context,q)}return b?{value:q}:q};case r.AssignmentExpression:return c=this.recurse(a.left,!0,1),e=this.recurse(a.right),function(a,d,f,g){var n=c(a,d,f,g);a=e(a,d,f,g);n.context[n.name]=a;return b?{value:a}:a};case r.ArrayExpression:return g=[],p(a.elements,function(a){g.push(f.recurse(a))}),function(a,c,d,e){for(var f=[],q=0;q<g.length;++q)f.push(g[q](a,c,d,e));return b?{value:f}:f};case r.ObjectExpression:return g=[],p(a.properties,
+function(a){a.computed?g.push({key:f.recurse(a.key),computed:!0,value:f.recurse(a.value)}):g.push({key:a.key.type===r.Identifier?a.key.name:""+a.key.value,computed:!1,value:f.recurse(a.value)})}),function(a,c,d,e){for(var f={},q=0;q<g.length;++q)g[q].computed?f[g[q].key(a,c,d,e)]=g[q].value(a,c,d,e):f[g[q].key]=g[q].value(a,c,d,e);return b?{value:f}:f};case r.ThisExpression:return function(a){return b?{value:a}:a};case r.LocalsExpression:return function(a,c){return b?{value:c}:c};case r.NGValueParameter:return function(a,
+c,d){return b?{value:d}:d}}},"unary+":function(a,b){return function(d,c,e,f){d=a(d,c,e,f);d=t(d)?+d:0;return b?{value:d}:d}},"unary-":function(a,b){return function(d,c,e,f){d=a(d,c,e,f);d=t(d)?-d:-0;return b?{value:d}:d}},"unary!":function(a,b){return function(d,c,e,f){d=!a(d,c,e,f);return b?{value:d}:d}},"binary+":function(a,b,d){return function(c,e,f,g){var k=a(c,e,f,g);c=b(c,e,f,g);k=Ed(k,c);return d?{value:k}:k}},"binary-":function(a,b,d){return function(c,e,f,g){var k=a(c,e,f,g);c=b(c,e,f,g);
+k=(t(k)?k:0)-(t(c)?c:0);return d?{value:k}:k}},"binary*":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)*b(c,e,f,g);return d?{value:c}:c}},"binary/":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)/b(c,e,f,g);return d?{value:c}:c}},"binary%":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)%b(c,e,f,g);return d?{value:c}:c}},"binary===":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)===b(c,e,f,g);return d?{value:c}:c}},"binary!==":function(a,b,d){return function(c,e,f,g){c=a(c,
+e,f,g)!==b(c,e,f,g);return d?{value:c}:c}},"binary==":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)==b(c,e,f,g);return d?{value:c}:c}},"binary!=":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)!=b(c,e,f,g);return d?{value:c}:c}},"binary<":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)<b(c,e,f,g);return d?{value:c}:c}},"binary>":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)>b(c,e,f,g);return d?{value:c}:c}},"binary<=":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,
+g)<=b(c,e,f,g);return d?{value:c}:c}},"binary>=":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)>=b(c,e,f,g);return d?{value:c}:c}},"binary&&":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)&&b(c,e,f,g);return d?{value:c}:c}},"binary||":function(a,b,d){return function(c,e,f,g){c=a(c,e,f,g)||b(c,e,f,g);return d?{value:c}:c}},"ternary?:":function(a,b,d,c){return function(e,f,g,k){e=a(e,f,g,k)?b(e,f,g,k):d(e,f,g,k);return c?{value:e}:e}},value:function(a,b){return function(){return b?{context:void 0,
+name:void 0,value:a}:a}},identifier:function(a,b,d){return function(c,e,f,g){c=e&&a in e?e:c;d&&1!==d&&c&&null==c[a]&&(c[a]={});e=c?c[a]:void 0;return b?{context:c,name:a,value:e}:e}},computedMember:function(a,b,d,c){return function(e,f,g,k){var h=a(e,f,g,k),l,m;null!=h&&(l=b(e,f,g,k),l+="",c&&1!==c&&h&&!h[l]&&(h[l]={}),m=h[l]);return d?{context:h,name:l,value:m}:m}},nonComputedMember:function(a,b,d,c){return function(e,f,g,k){e=a(e,f,g,k);c&&1!==c&&e&&null==e[b]&&(e[b]={});f=null!=e?e[b]:void 0;
+return d?{context:e,name:b,value:f}:f}},inputs:function(a,b){return function(d,c,e,f){return f?f[b]:a(d,c,e)}}};xc.prototype={constructor:xc,parse:function(a){a=this.ast.ast(a);var b=this.astCompiler.compile(a);b.literal=0===a.body.length||1===a.body.length&&(a.body[0].expression.type===r.Literal||a.body[0].expression.type===r.ArrayExpression||a.body[0].expression.type===r.ObjectExpression);b.constant=a.constant;return b}};var wa=M("$sce"),oa={HTML:"html",CSS:"css",URL:"url",RESOURCE_URL:"resourceUrl",
+JS:"js"},Ac=/_([a-z])/g,Gg=M("$compile"),X=u.document.createElement("a"),Od=ua(u.location.href);Pd.$inject=["$document"];ed.$inject=["$provide"];var Wd=22,Vd=".",Cc="0";Qd.$inject=["$locale"];Sd.$inject=["$locale"];var Rg={yyyy:da("FullYear",4,0,!1,!0),yy:da("FullYear",2,0,!0,!0),y:da("FullYear",1,0,!1,!0),MMMM:ob("Month"),MMM:ob("Month",!0),MM:da("Month",2,1),M:da("Month",1,1),LLLL:ob("Month",!1,!0),dd:da("Date",2),d:da("Date",1),HH:da("Hours",2),H:da("Hours",1),hh:da("Hours",2,-12),h:da("Hours",
+1,-12),mm:da("Minutes",2),m:da("Minutes",1),ss:da("Seconds",2),s:da("Seconds",1),sss:da("Milliseconds",3),EEEE:ob("Day"),EEE:ob("Day",!0),a:function(a,b){return 12>a.getHours()?b.AMPMS[0]:b.AMPMS[1]},Z:function(a,b,d){a=-1*d;return a=(0<=a?"+":"")+(Mb(Math[0<a?"floor":"ceil"](a/60),2)+Mb(Math.abs(a%60),2))},ww:Yd(2),w:Yd(1),G:Dc,GG:Dc,GGG:Dc,GGGG:function(a,b){return 0>=a.getFullYear()?b.ERANAMES[0]:b.ERANAMES[1]}},Qg=/((?:[^yMLdHhmsaZEwG']+)|(?:'(?:[^']|'')*')|(?:E+|y+|M+|L+|d+|H+|h+|m+|s+|a|Z|G+|w+))([\s\S]*)/,
+Pg=/^-?\d+$/;Rd.$inject=["$locale"];var Kg=ka(N),Lg=ka(wb);Td.$inject=["$parse"];var He=ka({restrict:"E",compile:function(a,b){if(!b.href&&!b.xlinkHref)return function(a,b){if("a"===b[0].nodeName.toLowerCase()){var e="[object SVGAnimatedString]"===ha.call(b.prop("href"))?"xlink:href":"href";b.on("click",function(a){b.attr(e)||a.preventDefault()})}}}}),xb={};p(Hb,function(a,b){function d(a,d,e){a.$watch(e[c],function(a){e.$set(b,!!a)})}if("multiple"!==a){var c=Ea("ng-"+b),e=d;"checked"===a&&(e=function(a,
+b,e){e.ngModel!==e[c]&&d(a,b,e)});xb[c]=function(){return{restrict:"A",priority:100,link:e}}}});p(sd,function(a,b){xb[b]=function(){return{priority:100,link:function(a,c,e){if("ngPattern"===b&&"/"===e.ngPattern.charAt(0)&&(c=e.ngPattern.match(Vg))){e.$set("ngPattern",new RegExp(c[1],c[2]));return}a.$watch(e[b],function(a){e.$set(b,a)})}}}});p(["src","srcset","href"],function(a){var b=Ea("ng-"+a);xb[b]=function(){return{priority:99,link:function(d,c,e){var f=a,g=a;"href"===a&&"[object SVGAnimatedString]"===
+ha.call(c.prop("href"))&&(g="xlinkHref",e.$attr[g]="xlink:href",f=null);e.$observe(b,function(b){b?(e.$set(g,b),Ca&&f&&c.prop(f,e[g])):"href"===a&&e.$set(g,null)})}}}});var Ob={$addControl:C,$$renameControl:function(a,b){a.$name=b},$removeControl:C,$setValidity:C,$setDirty:C,$setPristine:C,$setSubmitted:C};Nb.$inject=["$element","$attrs","$scope","$animate","$interpolate"];Nb.prototype={$rollbackViewValue:function(){p(this.$$controls,function(a){a.$rollbackViewValue()})},$commitViewValue:function(){p(this.$$controls,
+function(a){a.$commitViewValue()})},$addControl:function(a){Ia(a.$name,"input");this.$$controls.push(a);a.$name&&(this[a.$name]=a);a.$$parentForm=this},$$renameControl:function(a,b){var d=a.$name;this[d]===a&&delete this[d];this[b]=a;a.$name=b},$removeControl:function(a){a.$name&&this[a.$name]===a&&delete this[a.$name];p(this.$pending,function(b,d){this.$setValidity(d,null,a)},this);p(this.$error,function(b,d){this.$setValidity(d,null,a)},this);p(this.$$success,function(b,d){this.$setValidity(d,null,
+a)},this);db(this.$$controls,a);a.$$parentForm=Ob},$setDirty:function(){this.$$animate.removeClass(this.$$element,Za);this.$$animate.addClass(this.$$element,Tb);this.$dirty=!0;this.$pristine=!1;this.$$parentForm.$setDirty()},$setPristine:function(){this.$$animate.setClass(this.$$element,Za,Tb+" ng-submitted");this.$dirty=!1;this.$pristine=!0;this.$submitted=!1;p(this.$$controls,function(a){a.$setPristine()})},$setUntouched:function(){p(this.$$controls,function(a){a.$setUntouched()})},$setSubmitted:function(){this.$$animate.addClass(this.$$element,
+"ng-submitted");this.$submitted=!0;this.$$parentForm.$setSubmitted()}};ae({clazz:Nb,set:function(a,b,d){var c=a[b];c?-1===c.indexOf(d)&&c.push(d):a[b]=[d]},unset:function(a,b,d){var c=a[b];c&&(db(c,d),0===c.length&&delete a[b])}});var ie=function(a){return["$timeout","$parse",function(b,d){function c(a){return""===a?d('this[""]').assign:d(a).assign||C}return{name:"form",restrict:a?"EAC":"E",require:["form","^^?form"],controller:Nb,compile:function(d,f){d.addClass(Za).addClass(pb);var g=f.name?"name":
+a&&f.ngForm?"ngForm":!1;return{pre:function(a,d,e,f){var n=f[0];if(!("action"in e)){var q=function(b){a.$apply(function(){n.$commitViewValue();n.$setSubmitted()});b.preventDefault()};d[0].addEventListener("submit",q);d.on("$destroy",function(){b(function(){d[0].removeEventListener("submit",q)},0,!1)})}(f[1]||n.$$parentForm).$addControl(n);var p=g?c(n.$name):C;g&&(p(a,n),e.$observe(g,function(b){n.$name!==b&&(p(a,void 0),n.$$parentForm.$$renameControl(n,b),p=c(n.$name),p(a,n))}));d.on("$destroy",function(){n.$$parentForm.$removeControl(n);
+p(a,void 0);P(n,Ob)})}}}}}]},Ie=ie(),Ue=ie(!0),Sg=/^\d{4,}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+(?:[+-][0-2]\d:[0-5]\d|Z)$/,dh=/^[a-z][a-z\d.+-]*:\/*(?:[^:@]+(?::[^@]+)?@)?(?:[^\s:/?#]+|\[[a-f\d:]+])(?::\d+)?(?:\/[^?#]*)?(?:\?[^#]*)?(?:#.*)?$/i,eh=/^(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/,Tg=/^\s*(-|\+)?(\d+|(\d*(\.\d*)))([eE][+-]?\d+)?\s*$/,je=/^(\d{4,})-(\d{2})-(\d{2})$/,
+ke=/^(\d{4,})-(\d\d)-(\d\d)T(\d\d):(\d\d)(?::(\d\d)(\.\d{1,3})?)?$/,Kc=/^(\d{4,})-W(\d\d)$/,le=/^(\d{4,})-(\d\d)$/,me=/^(\d\d):(\d\d)(?::(\d\d)(\.\d{1,3})?)?$/,ce=S();p(["date","datetime-local","month","time","week"],function(a){ce[a]=!0});var ne={text:function(a,b,d,c,e,f){Wa(a,b,d,c,e,f);Fc(c)},date:qb("date",je,Pb(je,["yyyy","MM","dd"]),"yyyy-MM-dd"),"datetime-local":qb("datetimelocal",ke,Pb(ke,"yyyy MM dd HH mm ss sss".split(" ")),"yyyy-MM-ddTHH:mm:ss.sss"),time:qb("time",me,Pb(me,["HH","mm",
+"ss","sss"]),"HH:mm:ss.sss"),week:qb("week",Kc,function(a,b){if(ea(a))return a;if(D(a)){Kc.lastIndex=0;var d=Kc.exec(a);if(d){var c=+d[1],e=+d[2],f=d=0,g=0,k=0,h=Xd(c),e=7*(e-1);b&&(d=b.getHours(),f=b.getMinutes(),g=b.getSeconds(),k=b.getMilliseconds());return new Date(c,0,h.getDate()+e,d,f,g,k)}}return NaN},"yyyy-Www"),month:qb("month",le,Pb(le,["yyyy","MM"]),"yyyy-MM"),number:function(a,b,d,c,e,f){Gc(a,b,d,c);de(c);Wa(a,b,d,c,e,f);var g,k;if(t(d.min)||d.ngMin)c.$validators.min=function(a){return c.$isEmpty(a)||
+w(g)||a>=g},d.$observe("min",function(a){g=Xa(a);c.$validate()});if(t(d.max)||d.ngMax)c.$validators.max=function(a){return c.$isEmpty(a)||w(k)||a<=k},d.$observe("max",function(a){k=Xa(a);c.$validate()});if(t(d.step)||d.ngStep){var h;c.$validators.step=function(a,b){return c.$isEmpty(b)||w(h)||ee(b,g||0,h)};d.$observe("step",function(a){h=Xa(a);c.$validate()})}},url:function(a,b,d,c,e,f){Wa(a,b,d,c,e,f);Fc(c);c.$$parserName="url";c.$validators.url=function(a,b){var d=a||b;return c.$isEmpty(d)||dh.test(d)}},
+email:function(a,b,d,c,e,f){Wa(a,b,d,c,e,f);Fc(c);c.$$parserName="email";c.$validators.email=function(a,b){var d=a||b;return c.$isEmpty(d)||eh.test(d)}},radio:function(a,b,d,c){var e=!d.ngTrim||"false"!==Q(d.ngTrim);w(d.name)&&b.attr("name",++sb);b.on("click",function(a){var g;b[0].checked&&(g=d.value,e&&(g=Q(g)),c.$setViewValue(g,a&&a.type))});c.$render=function(){var a=d.value;e&&(a=Q(a));b[0].checked=a===c.$viewValue};d.$observe("value",c.$render)},range:function(a,b,d,c,e,f){function g(a,c){b.attr(a,
+d[a]);d.$observe(a,c)}function k(a){n=Xa(a);T(c.$modelValue)||(m?(a=b.val(),n>a&&(a=n,b.val(a)),c.$setViewValue(a)):c.$validate())}function h(a){q=Xa(a);T(c.$modelValue)||(m?(a=b.val(),q<a&&(b.val(q),a=q<n?n:q),c.$setViewValue(a)):c.$validate())}function l(a){p=Xa(a);T(c.$modelValue)||(m&&c.$viewValue!==b.val()?c.$setViewValue(b.val()):c.$validate())}Gc(a,b,d,c);de(c);Wa(a,b,d,c,e,f);var m=c.$$hasNativeValidators&&"range"===b[0].type,n=m?0:void 0,q=m?100:void 0,p=m?1:void 0,r=b[0].validity;a=t(d.min);
+e=t(d.max);f=t(d.step);var z=c.$render;c.$render=m&&t(r.rangeUnderflow)&&t(r.rangeOverflow)?function(){z();c.$setViewValue(b.val())}:z;a&&(c.$validators.min=m?function(){return!0}:function(a,b){return c.$isEmpty(b)||w(n)||b>=n},g("min",k));e&&(c.$validators.max=m?function(){return!0}:function(a,b){return c.$isEmpty(b)||w(q)||b<=q},g("max",h));f&&(c.$validators.step=m?function(){return!r.stepMismatch}:function(a,b){return c.$isEmpty(b)||w(p)||ee(b,n||0,p)},g("step",l))},checkbox:function(a,b,d,c,e,
+f,g,k){var h=fe(k,a,"ngTrueValue",d.ngTrueValue,!0),l=fe(k,a,"ngFalseValue",d.ngFalseValue,!1);b.on("click",function(a){c.$setViewValue(b[0].checked,a&&a.type)});c.$render=function(){b[0].checked=c.$viewValue};c.$isEmpty=function(a){return!1===a};c.$formatters.push(function(a){return sa(a,h)});c.$parsers.push(function(a){return a?h:l})},hidden:C,button:C,submit:C,reset:C,file:C},Zc=["$browser","$sniffer","$filter","$parse",function(a,b,d,c){return{restrict:"E",require:["?ngModel"],link:{pre:function(e,
+f,g,k){k[0]&&(ne[N(g.type)]||ne.text)(e,f,g,k[0],b,a,d,c)}}}}],fh=/^(true|false|\d+)$/,mf=function(){function a(a,d,c){var e=t(c)?c:9===Ca?"":null;a.prop("value",e);d.$set("value",c)}return{restrict:"A",priority:100,compile:function(b,d){return fh.test(d.ngValue)?function(b,d,f){b=b.$eval(f.ngValue);a(d,f,b)}:function(b,d,f){b.$watch(f.ngValue,function(b){a(d,f,b)})}}}},Me=["$compile",function(a){return{restrict:"AC",compile:function(b){a.$$addBindingClass(b);return function(b,c,e){a.$$addBindingInfo(c,
+e.ngBind);c=c[0];b.$watch(e.ngBind,function(a){c.textContent=dc(a)})}}}}],Oe=["$interpolate","$compile",function(a,b){return{compile:function(d){b.$$addBindingClass(d);return function(c,d,f){c=a(d.attr(f.$attr.ngBindTemplate));b.$$addBindingInfo(d,c.expressions);d=d[0];f.$observe("ngBindTemplate",function(a){d.textContent=w(a)?"":a})}}}}],Ne=["$sce","$parse","$compile",function(a,b,d){return{restrict:"A",compile:function(c,e){var f=b(e.ngBindHtml),g=b(e.ngBindHtml,function(b){return a.valueOf(b)});
+d.$$addBindingClass(c);return function(b,c,e){d.$$addBindingInfo(c,e.ngBindHtml);b.$watch(g,function(){var d=f(b);c.html(a.getTrustedHtml(d)||"")})}}}}],lf=ka({restrict:"A",require:"ngModel",link:function(a,b,d,c){c.$viewChangeListeners.push(function(){a.$eval(d.ngChange)})}}),Pe=Ic("",!0),Re=Ic("Odd",0),Qe=Ic("Even",1),Se=Qa({compile:function(a,b){b.$set("ngCloak",void 0);a.removeClass("ng-cloak")}}),Te=[function(){return{restrict:"A",scope:!0,controller:"@",priority:500}}],dd={},gh={blur:!0,focus:!0};
+p("click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste".split(" "),function(a){var b=Ea("ng-"+a);dd[b]=["$parse","$rootScope",function(d,c){return{restrict:"A",compile:function(e,f){var g=d(f[b]);return function(b,d){d.on(a,function(d){var e=function(){g(b,{$event:d})};gh[a]&&c.$$phase?b.$evalAsync(e):b.$apply(e)})}}}}]});var We=["$animate","$compile",function(a,b){return{multiElement:!0,transclude:"element",priority:600,
+terminal:!0,restrict:"A",$$tlb:!0,link:function(d,c,e,f,g){var k,h,l;d.$watch(e.ngIf,function(d){d?h||g(function(d,f){h=f;d[d.length++]=b.$$createComment("end ngIf",e.ngIf);k={clone:d};a.enter(d,c.parent(),c)}):(l&&(l.remove(),l=null),h&&(h.$destroy(),h=null),k&&(l=vb(k.clone),a.leave(l).done(function(a){!1!==a&&(l=null)}),k=null))})}}}],Xe=["$templateRequest","$anchorScroll","$animate",function(a,b,d){return{restrict:"ECA",priority:400,terminal:!0,transclude:"element",controller:$.noop,compile:function(c,
+e){var f=e.ngInclude||e.src,g=e.onload||"",k=e.autoscroll;return function(c,e,m,n,q){var p=0,r,z,v,s=function(){z&&(z.remove(),z=null);r&&(r.$destroy(),r=null);v&&(d.leave(v).done(function(a){!1!==a&&(z=null)}),z=v,v=null)};c.$watch(f,function(f){var m=function(a){!1===a||!t(k)||k&&!c.$eval(k)||b()},y=++p;f?(a(f,!0).then(function(a){if(!c.$$destroyed&&y===p){var b=c.$new();n.template=a;a=q(b,function(a){s();d.enter(a,null,e).done(m)});r=b;v=a;r.$emit("$includeContentLoaded",f);c.$eval(g)}},function(){c.$$destroyed||
+y!==p||(s(),c.$emit("$includeContentError",f))}),c.$emit("$includeContentRequested",f)):(s(),n.template=null)})}}}}],of=["$compile",function(a){return{restrict:"ECA",priority:-400,require:"ngInclude",link:function(b,d,c,e){ha.call(d[0]).match(/SVG/)?(d.empty(),a(fd(e.template,u.document).childNodes)(b,function(a){d.append(a)},{futureParentElement:d})):(d.html(e.template),a(d.contents())(b))}}}],Ye=Qa({priority:450,compile:function(){return{pre:function(a,b,d){a.$eval(d.ngInit)}}}}),kf=function(){return{restrict:"A",
+priority:100,require:"ngModel",link:function(a,b,d,c){var e=d.ngList||", ",f="false"!==d.ngTrim,g=f?Q(e):e;c.$parsers.push(function(a){if(!w(a)){var b=[];a&&p(a.split(g),function(a){a&&b.push(f?Q(a):a)});return b}});c.$formatters.push(function(a){if(I(a))return a.join(e)});c.$isEmpty=function(a){return!a||!a.length}}}},pb="ng-valid",$d="ng-invalid",Za="ng-pristine",Tb="ng-dirty",rb=M("ngModel");Qb.$inject="$scope $exceptionHandler $attrs $element $parse $animate $timeout $q $interpolate".split(" ");
+Qb.prototype={$$initGetterSetters:function(){if(this.$options.getOption("getterSetter")){var a=this.$$parse(this.$$attr.ngModel+"()"),b=this.$$parse(this.$$attr.ngModel+"($$$p)");this.$$ngModelGet=function(b){var c=this.$$parsedNgModel(b);A(c)&&(c=a(b));return c};this.$$ngModelSet=function(a,c){A(this.$$parsedNgModel(a))?b(a,{$$$p:c}):this.$$parsedNgModelAssign(a,c)}}else if(!this.$$parsedNgModel.assign)throw rb("nonassign",this.$$attr.ngModel,Aa(this.$$element));},$render:C,$isEmpty:function(a){return w(a)||
+""===a||null===a||a!==a},$$updateEmptyClasses:function(a){this.$isEmpty(a)?(this.$$animate.removeClass(this.$$element,"ng-not-empty"),this.$$animate.addClass(this.$$element,"ng-empty")):(this.$$animate.removeClass(this.$$element,"ng-empty"),this.$$animate.addClass(this.$$element,"ng-not-empty"))},$setPristine:function(){this.$dirty=!1;this.$pristine=!0;this.$$animate.removeClass(this.$$element,Tb);this.$$animate.addClass(this.$$element,Za)},$setDirty:function(){this.$dirty=!0;this.$pristine=!1;this.$$animate.removeClass(this.$$element,
+Za);this.$$animate.addClass(this.$$element,Tb);this.$$parentForm.$setDirty()},$setUntouched:function(){this.$touched=!1;this.$untouched=!0;this.$$animate.setClass(this.$$element,"ng-untouched","ng-touched")},$setTouched:function(){this.$touched=!0;this.$untouched=!1;this.$$animate.setClass(this.$$element,"ng-touched","ng-untouched")},$rollbackViewValue:function(){this.$$timeout.cancel(this.$$pendingDebounce);this.$viewValue=this.$$lastCommittedViewValue;this.$render()},$validate:function(){if(!T(this.$modelValue)){var a=
+this.$$lastCommittedViewValue,b=this.$$rawModelValue,d=this.$valid,c=this.$modelValue,e=this.$options.getOption("allowInvalid"),f=this;this.$$runValidators(b,a,function(a){e||d===a||(f.$modelValue=a?b:void 0,f.$modelValue!==c&&f.$$writeModelToScope())})}},$$runValidators:function(a,b,d){function c(){var c=!0;p(h.$validators,function(d,e){var g=Boolean(d(a,b));c=c&&g;f(e,g)});return c?!0:(p(h.$asyncValidators,function(a,b){f(b,null)}),!1)}function e(){var c=[],d=!0;p(h.$asyncValidators,function(e,
+g){var h=e(a,b);if(!h||!A(h.then))throw rb("nopromise",h);f(g,void 0);c.push(h.then(function(){f(g,!0)},function(){d=!1;f(g,!1)}))});c.length?h.$$q.all(c).then(function(){g(d)},C):g(!0)}function f(a,b){k===h.$$currentValidationRunId&&h.$setValidity(a,b)}function g(a){k===h.$$currentValidationRunId&&d(a)}this.$$currentValidationRunId++;var k=this.$$currentValidationRunId,h=this;(function(){var a=h.$$parserName||"parse";if(w(h.$$parserValid))f(a,null);else return h.$$parserValid||(p(h.$validators,function(a,
+b){f(b,null)}),p(h.$asyncValidators,function(a,b){f(b,null)})),f(a,h.$$parserValid),h.$$parserValid;return!0})()?c()?e():g(!1):g(!1)},$commitViewValue:function(){var a=this.$viewValue;this.$$timeout.cancel(this.$$pendingDebounce);if(this.$$lastCommittedViewValue!==a||""===a&&this.$$hasNativeValidators)this.$$updateEmptyClasses(a),this.$$lastCommittedViewValue=a,this.$pristine&&this.$setDirty(),this.$$parseAndValidate()},$$parseAndValidate:function(){var a=this.$$lastCommittedViewValue,b=this;if(this.$$parserValid=
+w(a)?void 0:!0)for(var d=0;d<this.$parsers.length;d++)if(a=this.$parsers[d](a),w(a)){this.$$parserValid=!1;break}T(this.$modelValue)&&(this.$modelValue=this.$$ngModelGet(this.$$scope));var c=this.$modelValue,e=this.$options.getOption("allowInvalid");this.$$rawModelValue=a;e&&(this.$modelValue=a,b.$modelValue!==c&&b.$$writeModelToScope());this.$$runValidators(a,this.$$lastCommittedViewValue,function(d){e||(b.$modelValue=d?a:void 0,b.$modelValue!==c&&b.$$writeModelToScope())})},$$writeModelToScope:function(){this.$$ngModelSet(this.$$scope,
+this.$modelValue);p(this.$viewChangeListeners,function(a){try{a()}catch(b){this.$$exceptionHandler(b)}},this)},$setViewValue:function(a,b){this.$viewValue=a;this.$options.getOption("updateOnDefault")&&this.$$debounceViewValueCommit(b)},$$debounceViewValueCommit:function(a){var b=this.$options.getOption("debounce");Y(b[a])?b=b[a]:Y(b["default"])&&(b=b["default"]);this.$$timeout.cancel(this.$$pendingDebounce);var d=this;0<b?this.$$pendingDebounce=this.$$timeout(function(){d.$commitViewValue()},b):this.$$scope.$root.$$phase?
+this.$commitViewValue():this.$$scope.$apply(function(){d.$commitViewValue()})},$overrideModelOptions:function(a){this.$options=this.$options.createChild(a)}};ae({clazz:Qb,set:function(a,b){a[b]=!0},unset:function(a,b){delete a[b]}});var jf=["$rootScope",function(a){return{restrict:"A",require:["ngModel","^?form","^?ngModelOptions"],controller:Qb,priority:1,compile:function(b){b.addClass(Za).addClass("ng-untouched").addClass(pb);return{pre:function(a,b,e,f){var g=f[0];b=f[1]||g.$$parentForm;if(f=f[2])g.$options=
+f.$options;g.$$initGetterSetters();b.$addControl(g);e.$observe("name",function(a){g.$name!==a&&g.$$parentForm.$$renameControl(g,a)});a.$on("$destroy",function(){g.$$parentForm.$removeControl(g)})},post:function(b,c,e,f){function g(){k.$setTouched()}var k=f[0];if(k.$options.getOption("updateOn"))c.on(k.$options.getOption("updateOn"),function(a){k.$$debounceViewValueCommit(a&&a.type)});c.on("blur",function(){k.$touched||(a.$$phase?b.$evalAsync(g):b.$apply(g))})}}}}}],Rb,hh=/(\s+|^)default(\s+|$)/;Jc.prototype=
+{getOption:function(a){return this.$$options[a]},createChild:function(a){var b=!1;a=P({},a);p(a,function(d,c){"$inherit"===d?"*"===c?b=!0:(a[c]=this.$$options[c],"updateOn"===c&&(a.updateOnDefault=this.$$options.updateOnDefault)):"updateOn"===c&&(a.updateOnDefault=!1,a[c]=Q(d.replace(hh,function(){a.updateOnDefault=!0;return" "})))},this);b&&(delete a["*"],ge(a,this.$$options));ge(a,Rb.$$options);return new Jc(a)}};Rb=new Jc({updateOn:"",updateOnDefault:!0,debounce:0,getterSetter:!1,allowInvalid:!1,
+timezone:null});var nf=function(){function a(a,d){this.$$attrs=a;this.$$scope=d}a.$inject=["$attrs","$scope"];a.prototype={$onInit:function(){var a=this.parentCtrl?this.parentCtrl.$options:Rb,d=this.$$scope.$eval(this.$$attrs.ngModelOptions);this.$options=a.createChild(d)}};return{restrict:"A",priority:10,require:{parentCtrl:"?^^ngModelOptions"},bindToController:!0,controller:a}},Ze=Qa({terminal:!0,priority:1E3}),ih=M("ngOptions"),jh=/^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+group\s+by\s+([\s\S]+?))?(?:\s+disable\s+when\s+([\s\S]+?))?\s+for\s+(?:([$\w][$\w]*)|(?:\(\s*([$\w][$\w]*)\s*,\s*([$\w][$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?$/,
+gf=["$compile","$document","$parse",function(a,b,d){function c(a,b,c){function e(a,b,c,d,f){this.selectValue=a;this.viewValue=b;this.label=c;this.group=d;this.disabled=f}function f(a){var b;if(!p&&xa(a))b=a;else{b=[];for(var c in a)a.hasOwnProperty(c)&&"$"!==c.charAt(0)&&b.push(c)}return b}var n=a.match(jh);if(!n)throw ih("iexp",a,Aa(b));var q=n[5]||n[7],p=n[6];a=/ as /.test(n[0])&&n[1];var r=n[9];b=d(n[2]?n[1]:q);var z=a&&d(a)||b,t=r&&d(r),s=r?function(a,b){return t(c,b)}:function(a){return Pa(a)},
+w=function(a,b){return s(a,A(a,b))},u=d(n[2]||n[1]),y=d(n[3]||""),J=d(n[4]||""),H=d(n[8]),B={},A=p?function(a,b){B[p]=b;B[q]=a;return B}:function(a){B[q]=a;return B};return{trackBy:r,getTrackByValue:w,getWatchables:d(H,function(a){var b=[];a=a||[];for(var d=f(a),e=d.length,g=0;g<e;g++){var k=a===d?g:d[g],l=a[k],k=A(l,k),l=s(l,k);b.push(l);if(n[2]||n[1])l=u(c,k),b.push(l);n[4]&&(k=J(c,k),b.push(k))}return b}),getOptions:function(){for(var a=[],b={},d=H(c)||[],g=f(d),k=g.length,n=0;n<k;n++){var q=d===
+g?n:g[n],p=A(d[q],q),t=z(c,p),q=s(t,p),v=u(c,p),G=y(c,p),p=J(c,p),t=new e(q,t,v,G,p);a.push(t);b[q]=t}return{items:a,selectValueMap:b,getOptionFromViewValue:function(a){return b[w(a)]},getViewValueFromOption:function(a){return r?pa(a.viewValue):a.viewValue}}}}}var e=u.document.createElement("option"),f=u.document.createElement("optgroup");return{restrict:"A",terminal:!0,require:["select","ngModel"],link:{pre:function(a,b,c,d){d[0].registerOption=C},post:function(d,k,h,l){function m(a){var b=(a=s.getOptionFromViewValue(a))&&
+a.element;b&&!b.selected&&(b.selected=!0);return a}function n(a,b){a.element=b;b.disabled=a.disabled;a.label!==b.label&&(b.label=a.label,b.textContent=a.label);b.value=a.selectValue}var q=l[0],r=l[1],w=h.multiple;l=0;for(var z=k.children(),v=z.length;l<v;l++)if(""===z[l].value){q.hasEmptyOption=!0;q.emptyOption=z.eq(l);break}k.empty();l=!!q.emptyOption;B(e.cloneNode(!1)).val("?");var s,u=c(h.ngOptions,k,d),A=b[0].createDocumentFragment();q.generateUnknownOptionValue=function(a){return"?"};w?(q.writeValue=
+function(a){if(s){var b=a&&a.map(m)||[];s.items.forEach(function(a){a.element.selected&&-1===Array.prototype.indexOf.call(b,a)&&(a.element.selected=!1)})}},q.readValue=function(){var a=k.val()||[],b=[];p(a,function(a){(a=s.selectValueMap[a])&&!a.disabled&&b.push(s.getViewValueFromOption(a))});return b},u.trackBy&&d.$watchCollection(function(){if(I(r.$viewValue))return r.$viewValue.map(function(a){return u.getTrackByValue(a)})},function(){r.$render()})):(q.writeValue=function(a){if(s){var b=k[0].options[k[0].selectedIndex],
+c=s.getOptionFromViewValue(a);b&&b.removeAttribute("selected");c?(k[0].value!==c.selectValue&&(q.removeUnknownOption(),k[0].value=c.selectValue,c.element.selected=!0),c.element.setAttribute("selected","selected")):q.selectUnknownOrEmptyOption(a)}},q.readValue=function(){var a=s.selectValueMap[k.val()];return a&&!a.disabled?(q.unselectEmptyOption(),q.removeUnknownOption(),s.getViewValueFromOption(a)):null},u.trackBy&&d.$watch(function(){return u.getTrackByValue(r.$viewValue)},function(){r.$render()}));
+l&&(a(q.emptyOption)(d),k.prepend(q.emptyOption),8===q.emptyOption[0].nodeType?(q.hasEmptyOption=!1,q.registerOption=function(a,b){""===b.val()&&(q.hasEmptyOption=!0,q.emptyOption=b,q.emptyOption.removeClass("ng-scope"),r.$render(),b.on("$destroy",function(){var a=q.$isEmptyOptionSelected();q.hasEmptyOption=!1;q.emptyOption=void 0;a&&r.$render()}))}):q.emptyOption.removeClass("ng-scope"));d.$watchCollection(u.getWatchables,function(){var a=s&&q.readValue();if(s)for(var b=s.items.length-1;0<=b;b--){var c=
+s.items[b];t(c.group)?Gb(c.element.parentNode):Gb(c.element)}s=u.getOptions();var d={};s.items.forEach(function(a){var b;if(t(a.group)){b=d[a.group];b||(b=f.cloneNode(!1),A.appendChild(b),b.label=null===a.group?"null":a.group,d[a.group]=b);var c=e.cloneNode(!1);b.appendChild(c);n(a,c)}else b=e.cloneNode(!1),A.appendChild(b),n(a,b)});k[0].appendChild(A);r.$render();r.$isEmpty(a)||(b=q.readValue(),(u.trackBy||w?sa(a,b):a===b)||(r.$setViewValue(b),r.$render()))})}}}}],$e=["$locale","$interpolate","$log",
+function(a,b,d){var c=/{}/g,e=/^when(Minus)?(.+)$/;return{link:function(f,g,k){function h(a){g.text(a||"")}var l=k.count,m=k.$attr.when&&g.attr(k.$attr.when),n=k.offset||0,q=f.$eval(m)||{},r={},t=b.startSymbol(),z=b.endSymbol(),v=t+l+"-"+n+z,s=$.noop,u;p(k,function(a,b){var c=e.exec(b);c&&(c=(c[1]?"-":"")+N(c[2]),q[c]=g.attr(k.$attr[b]))});p(q,function(a,d){r[d]=b(a.replace(c,v))});f.$watch(l,function(b){var c=parseFloat(b),e=T(c);e||c in q||(c=a.pluralCat(c-n));c===u||e&&T(u)||(s(),e=r[c],w(e)?(null!=
+b&&d.debug("ngPluralize: no rule defined for '"+c+"' in "+m),s=C,h()):s=f.$watch(e,h),u=c)})}}}],af=["$parse","$animate","$compile",function(a,b,d){var c=M("ngRepeat"),e=function(a,b,c,d,e,m,n){a[c]=d;e&&(a[e]=m);a.$index=b;a.$first=0===b;a.$last=b===n-1;a.$middle=!(a.$first||a.$last);a.$odd=!(a.$even=0===(b&1))};return{restrict:"A",multiElement:!0,transclude:"element",priority:1E3,terminal:!0,$$tlb:!0,compile:function(f,g){var k=g.ngRepeat,h=d.$$createComment("end ngRepeat",k),l=k.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/);
+if(!l)throw c("iexp",k);var m=l[1],n=l[2],q=l[3],r=l[4],l=m.match(/^(?:(\s*[$\w]+)|\(\s*([$\w]+)\s*,\s*([$\w]+)\s*\))$/);if(!l)throw c("iidexp",m);var t=l[3]||l[1],z=l[2];if(q&&(!/^[$a-zA-Z_][$a-zA-Z0-9_]*$/.test(q)||/^(null|undefined|this|\$index|\$first|\$middle|\$last|\$even|\$odd|\$parent|\$root|\$id)$/.test(q)))throw c("badident",q);var v,s,u,w,y={$id:Pa};r?v=a(r):(u=function(a,b){return Pa(b)},w=function(a){return a});return function(a,d,f,g,l){v&&(s=function(b,c,d){z&&(y[z]=b);y[t]=c;y.$index=
+d;return v(a,y)});var m=S();a.$watchCollection(n,function(f){var g,n,r=d[0],v,y=S(),B,A,G,C,E,D,I;q&&(a[q]=f);if(xa(f))E=f,n=s||u;else for(I in n=s||w,E=[],f)ra.call(f,I)&&"$"!==I.charAt(0)&&E.push(I);B=E.length;I=Array(B);for(g=0;g<B;g++)if(A=f===E?g:E[g],G=f[A],C=n(A,G,g),m[C])D=m[C],delete m[C],y[C]=D,I[g]=D;else{if(y[C])throw p(I,function(a){a&&a.scope&&(m[a.id]=a)}),c("dupes",k,C,G);I[g]={id:C,scope:void 0,clone:void 0};y[C]=!0}for(v in m){D=m[v];C=vb(D.clone);b.leave(C);if(C[0].parentNode)for(g=
+0,n=C.length;g<n;g++)C[g].$$NG_REMOVED=!0;D.scope.$destroy()}for(g=0;g<B;g++)if(A=f===E?g:E[g],G=f[A],D=I[g],D.scope){v=r;do v=v.nextSibling;while(v&&v.$$NG_REMOVED);D.clone[0]!==v&&b.move(vb(D.clone),null,r);r=D.clone[D.clone.length-1];e(D.scope,g,t,G,z,A,B)}else l(function(a,c){D.scope=c;var d=h.cloneNode(!1);a[a.length++]=d;b.enter(a,null,r);r=d;D.clone=a;y[D.id]=D;e(D.scope,g,t,G,z,A,B)});m=y})}}}}],bf=["$animate",function(a){return{restrict:"A",multiElement:!0,link:function(b,d,c){b.$watch(c.ngShow,
+function(b){a[b?"removeClass":"addClass"](d,"ng-hide",{tempClasses:"ng-hide-animate"})})}}}],Ve=["$animate",function(a){return{restrict:"A",multiElement:!0,link:function(b,d,c){b.$watch(c.ngHide,function(b){a[b?"addClass":"removeClass"](d,"ng-hide",{tempClasses:"ng-hide-animate"})})}}}],cf=Qa(function(a,b,d){a.$watch(d.ngStyle,function(a,d){d&&a!==d&&p(d,function(a,c){b.css(c,"")});a&&b.css(a)},!0)}),df=["$animate","$compile",function(a,b){return{require:"ngSwitch",controller:["$scope",function(){this.cases=
+{}}],link:function(d,c,e,f){var g=[],k=[],h=[],l=[],m=function(a,b){return function(c){!1!==c&&a.splice(b,1)}};d.$watch(e.ngSwitch||e.on,function(c){for(var d,e;h.length;)a.cancel(h.pop());d=0;for(e=l.length;d<e;++d){var r=vb(k[d].clone);l[d].$destroy();(h[d]=a.leave(r)).done(m(h,d))}k.length=0;l.length=0;(g=f.cases["!"+c]||f.cases["?"])&&p(g,function(c){c.transclude(function(d,e){l.push(e);var f=c.element;d[d.length++]=b.$$createComment("end ngSwitchWhen");k.push({clone:d});a.enter(d,f.parent(),
+f)})})})}}}],ef=Qa({transclude:"element",priority:1200,require:"^ngSwitch",multiElement:!0,link:function(a,b,d,c,e){a=d.ngSwitchWhen.split(d.ngSwitchWhenSeparator).sort().filter(function(a,b,c){return c[b-1]!==a});p(a,function(a){c.cases["!"+a]=c.cases["!"+a]||[];c.cases["!"+a].push({transclude:e,element:b})})}}),ff=Qa({transclude:"element",priority:1200,require:"^ngSwitch",multiElement:!0,link:function(a,b,d,c,e){c.cases["?"]=c.cases["?"]||[];c.cases["?"].push({transclude:e,element:b})}}),kh=M("ngTransclude"),
+hf=["$compile",function(a){return{restrict:"EAC",terminal:!0,compile:function(b){var d=a(b.contents());b.empty();return function(a,b,f,g,k){function h(){d(a,function(a){b.append(a)})}if(!k)throw kh("orphan",Aa(b));f.ngTransclude===f.$attr.ngTransclude&&(f.ngTransclude="");f=f.ngTransclude||f.ngTranscludeSlot;k(function(a,c){var d;if(d=a.length)a:{d=0;for(var f=a.length;d<f;d++){var g=a[d];if(g.nodeType!==Oa||g.nodeValue.trim()){d=!0;break a}}d=void 0}d?b.append(a):(h(),c.$destroy())},null,f);f&&!k.isSlotFilled(f)&&
+h()}}}}],Je=["$templateCache",function(a){return{restrict:"E",terminal:!0,compile:function(b,d){"text/ng-template"===d.type&&a.put(d.id,b[0].text)}}}],lh={$setViewValue:C,$render:C},mh=["$element","$scope",function(a,b){function d(){g||(g=!0,b.$$postDigest(function(){g=!1;e.ngModelCtrl.$render()}))}function c(a){k||(k=!0,b.$$postDigest(function(){b.$$destroyed||(k=!1,e.ngModelCtrl.$setViewValue(e.readValue()),a&&e.ngModelCtrl.$render())}))}var e=this,f=new Ib;e.selectValueMap={};e.ngModelCtrl=lh;
+e.multiple=!1;e.unknownOption=B(u.document.createElement("option"));e.hasEmptyOption=!1;e.emptyOption=void 0;e.renderUnknownOption=function(b){b=e.generateUnknownOptionValue(b);e.unknownOption.val(b);a.prepend(e.unknownOption);Ga(e.unknownOption,!0);a.val(b)};e.updateUnknownOption=function(b){b=e.generateUnknownOptionValue(b);e.unknownOption.val(b);Ga(e.unknownOption,!0);a.val(b)};e.generateUnknownOptionValue=function(a){return"? "+Pa(a)+" ?"};e.removeUnknownOption=function(){e.unknownOption.parent()&&
+e.unknownOption.remove()};e.selectEmptyOption=function(){e.emptyOption&&(a.val(""),Ga(e.emptyOption,!0))};e.unselectEmptyOption=function(){e.hasEmptyOption&&Ga(e.emptyOption,!1)};b.$on("$destroy",function(){e.renderUnknownOption=C});e.readValue=function(){var b=a.val(),b=b in e.selectValueMap?e.selectValueMap[b]:b;return e.hasOption(b)?b:null};e.writeValue=function(b){var c=a[0].options[a[0].selectedIndex];c&&Ga(B(c),!1);e.hasOption(b)?(e.removeUnknownOption(),c=Pa(b),a.val(c in e.selectValueMap?
+c:b),Ga(B(a[0].options[a[0].selectedIndex]),!0)):e.selectUnknownOrEmptyOption(b)};e.addOption=function(a,b){if(8!==b[0].nodeType){Ia(a,'"option value"');""===a&&(e.hasEmptyOption=!0,e.emptyOption=b);var c=f.get(a)||0;f.set(a,c+1);d()}};e.removeOption=function(a){var b=f.get(a);b&&(1===b?(f.delete(a),""===a&&(e.hasEmptyOption=!1,e.emptyOption=void 0)):f.set(a,b-1))};e.hasOption=function(a){return!!f.get(a)};e.$hasEmptyOption=function(){return e.hasEmptyOption};e.$isUnknownOptionSelected=function(){return a[0].options[0]===
+e.unknownOption[0]};e.$isEmptyOptionSelected=function(){return e.hasEmptyOption&&a[0].options[a[0].selectedIndex]===e.emptyOption[0]};e.selectUnknownOrEmptyOption=function(a){null==a&&e.emptyOption?(e.removeUnknownOption(),e.selectEmptyOption()):e.unknownOption.parent().length?e.updateUnknownOption(a):e.renderUnknownOption(a)};var g=!1,k=!1;e.registerOption=function(a,b,f,g,k){if(f.$attr.ngValue){var p,r=NaN;f.$observe("value",function(a){var d,f=b.prop("selected");t(r)&&(e.removeOption(p),delete e.selectValueMap[r],
+d=!0);r=Pa(a);p=a;e.selectValueMap[r]=a;e.addOption(a,b);b.attr("value",r);d&&f&&c()})}else g?f.$observe("value",function(a){e.readValue();var d,f=b.prop("selected");t(p)&&(e.removeOption(p),d=!0);p=a;e.addOption(a,b);d&&f&&c()}):k?a.$watch(k,function(a,d){f.$set("value",a);var g=b.prop("selected");d!==a&&e.removeOption(d);e.addOption(a,b);d&&g&&c()}):e.addOption(f.value,b);f.$observe("disabled",function(a){if("true"===a||a&&b.prop("selected"))e.multiple?c(!0):(e.ngModelCtrl.$setViewValue(null),e.ngModelCtrl.$render())});
+b.on("$destroy",function(){var a=e.readValue(),b=f.value;e.removeOption(b);d();(e.multiple&&a&&-1!==a.indexOf(b)||a===b)&&c(!0)})}}],Ke=function(){return{restrict:"E",require:["select","?ngModel"],controller:mh,priority:1,link:{pre:function(a,b,d,c){var e=c[0],f=c[1];if(f){if(e.ngModelCtrl=f,b.on("change",function(){e.removeUnknownOption();a.$apply(function(){f.$setViewValue(e.readValue())})}),d.multiple){e.multiple=!0;e.readValue=function(){var a=[];p(b.find("option"),function(b){b.selected&&!b.disabled&&
+(b=b.value,a.push(b in e.selectValueMap?e.selectValueMap[b]:b))});return a};e.writeValue=function(a){p(b.find("option"),function(b){var c=!!a&&(-1!==Array.prototype.indexOf.call(a,b.value)||-1!==Array.prototype.indexOf.call(a,e.selectValueMap[b.value]));c!==b.selected&&Ga(B(b),c)})};var g,k=NaN;a.$watch(function(){k!==f.$viewValue||sa(g,f.$viewValue)||(g=ja(f.$viewValue),f.$render());k=f.$viewValue});f.$isEmpty=function(a){return!a||0===a.length}}}else e.registerOption=C},post:function(a,b,d,c){var e=
+c[1];if(e){var f=c[0];e.$render=function(){f.writeValue(e.$viewValue)}}}}}},Le=["$interpolate",function(a){return{restrict:"E",priority:100,compile:function(b,d){var c,e;t(d.ngValue)||(t(d.value)?c=a(d.value,!0):(e=a(b.text(),!0))||d.$set("value",b.text()));return function(a,b,d){var h=b.parent();(h=h.data("$selectController")||h.parent().data("$selectController"))&&h.registerOption(a,b,d,c,e)}}}}],ad=function(){return{restrict:"A",require:"?ngModel",link:function(a,b,d,c){c&&(d.required=!0,c.$validators.required=
+function(a,b){return!d.required||!c.$isEmpty(b)},d.$observe("required",function(){c.$validate()}))}}},$c=function(){return{restrict:"A",require:"?ngModel",link:function(a,b,d,c){if(c){var e,f=d.ngPattern||d.pattern;d.$observe("pattern",function(a){D(a)&&0<a.length&&(a=new RegExp("^"+a+"$"));if(a&&!a.test)throw M("ngPattern")("noregexp",f,a,Aa(b));e=a||void 0;c.$validate()});c.$validators.pattern=function(a,b){return c.$isEmpty(b)||w(e)||e.test(b)}}}}},cd=function(){return{restrict:"A",require:"?ngModel",
+link:function(a,b,d,c){if(c){var e=-1;d.$observe("maxlength",function(a){a=Z(a);e=T(a)?-1:a;c.$validate()});c.$validators.maxlength=function(a,b){return 0>e||c.$isEmpty(b)||b.length<=e}}}}},bd=function(){return{restrict:"A",require:"?ngModel",link:function(a,b,d,c){if(c){var e=0;d.$observe("minlength",function(a){e=Z(a)||0;c.$validate()});c.$validators.minlength=function(a,b){return c.$isEmpty(b)||b.length>=e}}}}};u.angular.bootstrap?u.console&&console.log("WARNING: Tried to load angular more than once."):
+(Be(),Ee($),$.module("ngLocale",[],["$provide",function(a){function b(a){a+="";var b=a.indexOf(".");return-1==b?0:a.length-b-1}a.value("$locale",{DATETIME_FORMATS:{AMPMS:["AM","PM"],DAY:"Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" "),ERANAMES:["Before Christ","Anno Domini"],ERAS:["BC","AD"],FIRSTDAYOFWEEK:6,MONTH:"January February March April May June July August September October November December".split(" "),SHORTDAY:"Sun Mon Tue Wed Thu Fri Sat".split(" "),SHORTMONTH:"Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "),
+STANDALONEMONTH:"January February March April May June July August September October November December".split(" "),WEEKENDRANGE:[5,6],fullDate:"EEEE, MMMM d, y",longDate:"MMMM d, y",medium:"MMM d, y h:mm:ss a",mediumDate:"MMM d, y",mediumTime:"h:mm:ss a","short":"M/d/yy h:mm a",shortDate:"M/d/yy",shortTime:"h:mm a"},NUMBER_FORMATS:{CURRENCY_SYM:"$",DECIMAL_SEP:".",GROUP_SEP:",",PATTERNS:[{gSize:3,lgSize:3,maxFrac:3,minFrac:0,minInt:1,negPre:"-",negSuf:"",posPre:"",posSuf:""},{gSize:3,lgSize:3,maxFrac:2,
+minFrac:2,minInt:1,negPre:"-\u00a4",negSuf:"",posPre:"\u00a4",posSuf:""}]},id:"en-us",localeID:"en_US",pluralCat:function(a,c){var e=a|0,f=c;void 0===f&&(f=Math.min(b(a),3));Math.pow(10,f);return 1==e&&0==f?"one":"other"}})}]),B(function(){we(u.document,Uc)}))})(window);!window.angular.$$csp().noInlineStyle&&window.angular.element(document.head).prepend('<style type="text/css">@charset "UTF-8";[ng\\:cloak],[ng-cloak],[data-ng-cloak],[x-ng-cloak],.ng-cloak,.x-ng-cloak,.ng-hide:not(.ng-hide-animate){display:none !important;}ng\\:form{display:block;}.ng-animate-shim{visibility:hidden;}.ng-anchor{position:absolute;}</style>');
+//# sourceMappingURL=angular.min.js.map

文件差异内容过多而无法显示
+ 5 - 0
src/pineapple/js/vendor/bootstrap.min.js


文件差异内容过多而无法显示
+ 1 - 0
src/pineapple/js/vendor/jquery.min.js


+ 276 - 0
src/pineapple/modules/Advanced/api/module.php

@@ -0,0 +1,276 @@
+<?php namespace pineapple;
+
+class Advanced extends SystemModule
+{
+    private $dbConnection;
+
+    const DATABASE = "/etc/pineapple/pineapple.db";
+
+    public function __construct($request)
+    {
+        parent::__construct($request, __CLASS__);
+        $this->dbConnection = new DatabaseConnection(self::DATABASE);
+        $this->dbConnection->exec("CREATE TABLE IF NOT EXISTS api_tokens (token VARCHAR NOT NULL, name VARCHAR NOT NULL);");
+    }
+
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'getResources':
+                $this->getResources();
+                break;
+
+            case 'dropCaches':
+                $this->dropCaches();
+                break;
+
+            case 'getUSB':
+                $this->getUSB();
+                break;
+
+            case 'getFstab':
+                $this->getFstab();
+                break;
+
+            case 'saveFstab':
+                $this->saveFstab();
+                break;
+
+            case 'getCSS':
+                $this->getCSS();
+                break;
+
+            case 'saveCSS':
+                $this->saveCSS();
+                break;
+
+            case 'formatSDCard':
+                if ($this->sdReaderPresent()) {
+                    $this->formatSDCard();
+                }
+                break;
+
+            case 'formatSDCardStatus':
+                $this->formatSDCardStatus();
+                break;
+
+            case 'checkForUpgrade':
+                $this->checkForUpgrade();
+                break;
+
+            case 'downloadUpgrade':
+                $this->downloadUpgrade();
+                break;
+
+            case 'getDownloadStatus':
+                $this->getDownloadStatus();
+                break;
+
+            case 'performUpgrade':
+                $this->performUpgrade();
+                break;
+
+            case 'getCurrentVersion':
+                $this->getCurrentVersion();
+                break;
+
+            case 'checkApiToken':
+                $this->checkApiToken();
+                break;
+
+            case 'addApiToken':
+                $this->addApiToken();
+                break;
+
+            case 'getApiTokens':
+                $this->getApiTokens();
+                break;
+
+            case 'revokeApiToken':
+                $this->revokeApiToken();
+                break;
+        }
+    }
+
+    private function getResources()
+    {
+        exec('df -h', $freeDisk);
+        $freeDisk = implode("\n", $freeDisk);
+
+        exec('free -m', $freeMem);
+        $freeMem = implode("\n", $freeMem);
+
+        $this->response = array("freeDisk" => $freeDisk, "freeMem" => $freeMem);
+    }
+
+    private function dropCaches()
+    {
+        $this->execBackground('echo 3 > /proc/sys/vm/drop_caches');
+        $this->response = array('success' => true);
+    }
+
+    private function getUSB()
+    {
+        exec('lsusb', $lsusb);
+        $lsusb = implode("\n", $lsusb);
+
+        $this->response = array('lsusb' => $lsusb);
+    }
+
+    private function getFstab()
+    {
+        $fstab = file_get_contents('/etc/config/fstab');
+        $this->response = array('fstab' => $fstab);
+    }
+
+    private function saveFstab()
+    {
+        if (isset($this->request->fstab)) {
+            file_put_contents('/etc/config/fstab', $this->request->fstab);
+            $this->response = array("success" => true);
+        }
+    }
+
+    private function getCSS()
+    {
+        $css = file_get_contents('/pineapple/css/main.css');
+        $this->response = array('css' => $css);
+    }
+
+    private function saveCSS()
+    {
+        if (isset($this->request->css)) {
+            file_put_contents('/pineapple/css/main.css', $this->request->css);
+            $this->response = array("success" => true);
+        }
+    }
+
+    private function checkForUpgrade()
+    {
+        $device = $this->getDevice();
+        $upgradeData = @file_get_contents("https://www.wifipineapple.com/{$device}/upgrades");
+
+        if ($upgradeData !== false) {
+            $upgradeData = json_decode($upgradeData);
+            if (json_last_error() === JSON_ERROR_NONE) {
+                if ($this->compareFirmwareVersion($upgradeData->version) === true) {
+                    if ($upgradeData->hotpatch != null) {
+                        $hotpatch = base64_decode($upgradeData->hotpatch);
+                        file_put_contents($hotpatch, "/tmp/hotpatch.patch");
+                    }
+                    $this->response = array("upgrade" => true, "upgradeData" => $upgradeData);
+                } else {
+                    $this->error = "No upgrade found.";
+                }
+            }
+        } else {
+            $this->error = "Error connecting to WiFiPineapple.com. Please check your connection.";
+        }
+
+    }
+
+    private function downloadUpgrade()
+    {
+        if (file_exists('/tmp/hotpatch.patch')) {
+            exec("cd / && patch < /tmp/hotpatch.patch");
+        }
+        $version = $this->request->version;
+        $device = $this->getDevice();
+        @unlink("/tmp/upgrade.bin");
+        @unlink("/tmp/upgradeDownloaded");
+        $this->execBackground("wget 'https://www.wifipineapple.com/{$device}/upgrades/{$version}' -O /tmp/upgrade.bin && touch /tmp/upgradeDownloaded");
+        $this->response = array("success" => true);
+    }
+
+    private function getDownloadStatus()
+    {
+        if (file_exists("/tmp/upgradeDownloaded")) {
+            if (hash_file('sha256', '/tmp/upgrade.bin') == $this->request->checksum) {
+                $this->response = array("completed" => true);
+            } else {
+                $this->error = "Checksum mismatch";
+            }
+        } else {
+            $this->response = array("completed" => false, "downloaded" => filesize('/tmp/upgrade.bin'));
+        }
+    }
+
+    private function performUpgrade()
+    {
+        if (file_exists('/tmp/upgrade.bin')) {
+            $size = escapeshellarg(filesize('/tmp/upgrade.bin') - 33);
+            exec("dd if=/dev/null of=/tmp/upgrade.bin bs=1 seek={$size}");
+            $this->execBackground("sysupgrade -n /tmp/upgrade.bin");
+            $this->response = array("success" => true);
+        } else {
+            $this->error = "Upgrade failed.";
+        }
+    }
+
+    private function compareFirmwareVersion($version)
+    {
+        return version_compare($this->getFirmwareVersion(), $version, '<');
+    }
+
+    private function getCurrentVersion()
+    {
+        $this->response = array("firmwareVersion" => $this->getFirmwareVersion());
+    }
+
+    private function formatSDCard()
+    {
+        $this->execBackground("/pineapple/modules/Advanced/formatSD/format_sd");
+        $this->response = array('success' => true);
+    }
+
+    private function formatSDCardStatus()
+    {
+        if (!file_exists('/tmp/sd_format.progress')) {
+            $this->response = array('success' => true);
+        } else {
+            $this->response = array('success' => false);
+        }
+    }
+
+    private function getApiTokens()
+    {
+        $this->response = array("tokens" => $this->dbConnection->query("SELECT ROWID, token, name FROM api_tokens;"));
+    }
+
+    private function checkApiToken()
+    {
+        if (isset($this->request->token)) {
+            $token = $this->request->token;
+            $result = $this->dbConnection->query("SELECT token FROM api_tokens WHERE token='%s';", $token);
+            if (!empty($result) && isset($result[0]["token"]) && $result[0]["token"] === $token) {
+                $this->response = array("valid" => true);
+            }
+        }
+        $this->response = array("valid" => false);
+    }
+
+    private function addApiToken()
+    {
+        if (isset($this->request->name)) {
+            $token = hash('sha512', openssl_random_pseudo_bytes(32));
+            $name = $this->request->name;
+            $this->dbConnection->exec("INSERT INTO api_tokens(token, name) VALUES('%s','%s');", $token, $name);
+            $this->response = array("success" => true, "token" => $token, "name" => $name);
+        } else {
+            $this->error = "Missing token name";
+        }
+    }
+
+    private function revokeApiToken()
+    {
+        if (isset($this->request->id)) {
+            $this->dbConnection->exec("DELETE FROM api_tokens WHERE ROWID='%s'", $this->request->id);
+        } elseif (isset($this->request->token)) {
+            $this->dbConnection->exec("DELETE FROM api_tokens WHERE token='%s'", $this->request->token);
+        } elseif (isset($this->request->name)) {
+            $this->dbConnection->exec("DELETE FROM api_tokens WHERE name='%s'", $this->request->name);
+        } else {
+            $this->error = "The revokeApiToken API call requires either a 'id', 'token', or 'name' parameter";
+        }
+    }
+}

+ 15 - 0
src/pineapple/modules/Advanced/formatSD/fdisk_options

@@ -0,0 +1,15 @@
+o
+n
+p
+2
+
++1024M
+n
+p
+1
+
+
+w
+
+
+

+ 42 - 0
src/pineapple/modules/Advanced/formatSD/format_sd

@@ -0,0 +1,42 @@
+#!/bin/bash
+#2013 - WiFiPineapple.com
+
+#[[ -f /tmp/sd_format.progress ]] && {
+#  exit 0
+#}
+
+#Function to find and reset the SD device
+function reset_sd {
+  DEVICE=$(find / -name idProduct -exec grep -l 0745 {} + | awk -F '/' '{ print $(NF-1) }')
+  echo '$DEVICE' > /sys/bus/usb/drivers/usb/unbind > /dev/null 2>&1
+  sleep 2
+  echo '$DEVICE' > /sys/bus/usb/drivers/usb/bind > /dev/null 2>&1
+}
+
+touch /tmp/sd_format.progress
+
+umount /sd
+swapoff /dev/sdcard/sd2
+
+reset_sd
+sleep 5
+
+sleep 2
+cat /pineapple/modules/Advanced/formatSD/fdisk_options | fdisk /dev/sdcard/sd
+sleep 2
+
+umount /sd
+mkfs.ext4 -F /dev/sdcard/sd1
+
+sleep 2
+
+swapoff /dev/sdcard/sd2
+mkfs.ext4 -F /dev/sdcard/sd2
+
+
+mkswap /dev/sdcard/sd2
+
+mount /dev/sdcard/sd1 /sd
+swapon /dev/sdcard/sd2
+
+rm /tmp/sd_format.progress

+ 293 - 0
src/pineapple/modules/Advanced/js/module.js

@@ -0,0 +1,293 @@
+registerController("AdvancedResourcesController", ['$api', '$scope', '$timeout', function($api, $scope, $timeout){
+    $scope.freeDisk = "";
+    $scope.freeMem = "";
+    $scope.droppedCaches = false;
+    $scope.device = undefined;
+
+    $api.request({
+        module: 'Advanced',
+        action: 'getResources'
+    }, function(response){
+        $scope.freeDisk = response.freeDisk;
+        $scope.freeMem = response.freeMem;
+    });
+
+    $scope.dropCaches = (function() {
+        $api.request({
+            module: 'Advanced',
+            action: 'dropCaches'
+        }, function(response) {
+            if (response.success === true) {
+                $scope.droppedCaches = true;
+                $timeout(function(){
+                    $scope.droppedCaches = false;
+                }, 2000);
+            }
+        });
+    });
+
+    $scope.getDevice = (function() {
+        $api.request({
+            module: 'Configuration',
+            action: 'getDevice'
+        }, function(response) {
+            $scope.device = response.device;
+        });
+    });
+    $scope.getDevice();
+
+    $api.onDeviceIdentified(function(device, scope) {
+        scope.device = device;
+    }, $scope);
+}]);
+
+registerController("AdvancedUSBController", ['$api', '$scope', '$timeout', '$interval', function($api, $scope, $timeout, $interval){
+    $scope.formattingSDCard = false;
+    $scope.lsusb = "";
+    $scope.fstab = "";
+    $scope.fstabSaved = false;
+    $scope.device = "";
+
+    $scope.getDevice = (function() {
+        $api.request({
+            module: 'Configuration',
+            action: 'getDevice'
+        }, function(response) {
+            $scope.device = response.device;
+        });
+    });
+    $scope.getDevice();
+
+    $scope.formatSDCard = (function() {
+        $api.request({
+            module: 'Advanced',
+            action: 'formatSDCard'
+        }, function(response){
+            if (response.success === true) {
+                $scope.formattingSDCard = true;
+
+                $scope.SDCardInterval = $interval(function(){
+                    $api.request({
+                        module: 'Advanced',
+                        action: 'formatSDCardStatus'
+                    }, function(response) {
+                        if (response.success === true){
+                            $scope.formattingSDCard = false;
+                            $scope.formatSuccess = true;
+                            $interval.cancel($scope.SDCardInterval);
+                            $timeout(function(){
+                                $scope.formatSuccess = false;
+                            }, 2000);
+                        }
+                    });
+                }, 5000);
+            }
+        });
+    });
+
+    $api.request({
+        module: 'Advanced',
+        action: 'getUSB'
+    }, function(response){
+        $scope.lsusb = response.lsusb;
+    });
+
+    $api.request({
+        module: 'Advanced',
+        action: 'getFstab'
+    }, function(response) {
+        if (response.error === undefined) {
+            $scope.fstab = response.fstab;
+        }
+    });
+
+    $scope.saveFstab = (function() {
+        $api.request({
+            module: 'Advanced',
+            action: 'saveFstab',
+            fstab: $scope.fstab
+        }, function(response) {
+            if (response.success === true) {
+                $scope.fstabSaved = true;
+                $timeout(function(){
+                    $scope.fstabSaved = false;
+                }, 2000);
+            }
+        });
+    });
+
+    $scope.$on('$destroy', function() {
+        $interval.cancel($scope.SDCardInterval);
+    });
+}]);
+
+registerController("AdvancedCSSController", ['$api', '$scope', '$timeout', function($api, $scope, $timeout){
+    $scope.css = "";
+    $scope.cssSaved = false;
+
+    $api.request({
+        module: 'Advanced',
+        action: 'getCSS'
+    }, function(response) {
+        if (response.error === undefined) {
+            $scope.css = response.css;
+        }
+    });
+
+    $scope.saveCSS = (function() {
+        $api.request({
+            module: 'Advanced',
+            action: 'saveCSS',
+            css: $scope.css
+        }, function(response) {
+            if (response.success === true) {
+                $scope.cssSaved = true;
+                $timeout(function(){
+                    $scope.cssSaved = false;
+                }, 2000);
+            }
+        });
+    });
+}]);
+
+registerController("AdvancedUpgradeController", ['$api', '$scope', '$interval', function($api, $scope, $interval){
+    $scope.error = "";
+    $scope.loading = false;
+    $scope.upgradeFound = false;
+    $scope.downloadInterval = false;
+    $scope.downloading = false;
+    $scope.downloaded = false;
+    $scope.upgradeData = {};
+    $scope.downloadPercentage = 0;
+    $scope.firmwareVersion = "";
+
+    $api.request({
+        module: 'Advanced',
+        action: 'getCurrentVersion'
+    }, function(response) {
+        if (response.error === undefined) {
+            $scope.firmwareVersion = response.firmwareVersion;
+        }
+    });
+
+    $scope.checkForUpgrade = (function() {
+        $scope.loading = true;
+        $api.request({
+            module: 'Advanced',
+            action: 'checkForUpgrade'
+        }, function(response) {
+            $scope.loading = false;
+            if (response.error) {
+                $scope.error = response.error;
+            } else if (response.upgrade) {
+                $scope.upgradeFound = true;
+                $scope.upgradeData = response.upgradeData;
+                $scope.error = false;
+            }
+        });
+    });
+
+    $scope.downloadUpgrade = (function() {
+        $api.request({
+            module: 'Advanced',
+            action: 'downloadUpgrade',
+            version: $scope.upgradeData['version']
+        }, function(response) {
+            if (response.success === true) {
+                $scope.downloading = true;
+                $scope.downloadInterval = $interval(function() {
+                    $scope.getDownloadStatus();
+                }, 1000);
+            }
+        });
+    });
+
+    $scope.getDownloadStatus = (function() {
+        $api.request({
+            module: 'Advanced',
+            action: 'getDownloadStatus',
+            checksum: $scope.upgradeData['checksum']
+        }, function(response) {
+            if ($scope.downloaded) return;
+            if (response.completed === true) {
+                $scope.downloading = false;
+                $scope.downloaded = true;
+                $interval.cancel($scope.downloadInterval);
+                $scope.performUpgrade();
+            } else if (response.error) {
+                $scope.error = response.error;
+            } else {
+                $scope.downloadPercentage = Math.round((response.downloaded / $scope.upgradeData['size']) * 100);
+            }
+        });
+    });
+
+    $scope.performUpgrade = (function() {
+        $api.request({
+            module: 'Advanced',
+            action: 'performUpgrade'
+        }, function(response) {
+            if (response.success === true) {
+            }
+        });
+    });
+
+    $scope.$on('$destroy', function() {
+        $interval.cancel($scope.downloadInterval);
+    });
+}]);
+
+registerController("APITokenController", ['$api', '$scope', function($api, $scope) {
+    $scope.apiTokens = [];
+    $scope.newToken = {
+        name: "",
+        token: ""
+    };
+
+    $scope.getApiTokens = function(){
+        $api.request({
+            module: 'Advanced',
+            action: 'getApiTokens'
+        }, function(response){
+            $scope.apiTokens = response.tokens;
+        });
+    };
+
+    $scope.genApiToken = function(){
+        $api.request({
+            module: 'Advanced',
+            action: 'addApiToken',
+            name: $scope.newToken.name
+        }, function(response){
+            $scope.newToken.name = "";
+            $scope.newToken.token = response.token;
+            $scope.getApiTokens();
+        });
+    };
+
+    $scope.revokeApiToken = function($event){
+        var id = $event.target.getAttribute('tokenid');
+        $api.request({
+            module: 'Advanced',
+            action: 'revokeApiToken',
+            id: id
+        }, function(){
+            $scope.getApiTokens();
+        });
+    };
+
+    $scope.selectElem = function(elem){
+        var selectRange = document.createRange();
+        selectRange.selectNodeContents(elem);
+        var selection = window.getSelection();
+        selection.removeAllRanges();
+        selection.addRange(selectRange);
+    };
+
+    $scope.selectOnClick = function($event){
+        var elem = $event.target;
+        $scope.selectElem(elem);
+    };
+
+    $scope.getApiTokens();
+}]);

+ 185 - 0
src/pineapple/modules/Advanced/module.html

@@ -0,0 +1,185 @@
+<div class="row">
+    <div class="col-md-6" ng-controller="AdvancedResourcesController">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">
+                    Resources
+                    <span class="dropdown">
+                        <button class="btn btn-xs btn-default dropdown-toggle" type="button" id="resourcesDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <span class="caret"></span>
+                        </button>
+                        <ul class="dropdown-menu" aria-labelledby="generalDropdown">
+                            <li ng-click="dropCaches()"><a>Drop Page Caches</a></li>
+                        </ul>
+                    </span>
+                </h3>
+            </div>
+            <div class="panel-body">
+                <div class="alert well-sm alert-success ng-hide" ng-show="droppedCaches">Successfully dropped caches.</div>
+                <pre class="scrollable-pre">{{ freeDisk }}</pre>
+                <br/>
+                <pre class="scrollable-pre">{{ freeMem }}</pre>
+            </div>
+        </div>
+    </div>
+    <div class="col-md-6" ng-controller="AdvancedUSBController">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">
+                    USB &amp; Storage
+                    <span class="dropdown" ng-if="device == 'nano'">
+                        <button class="btn btn-xs btn-default dropdown-toggle" type="button" id="usbDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <span class="caret"></span>
+                        </button>
+                        <ul class="dropdown-menu" aria-labelledby="generalDropdown">
+                            <li ng-click="formatSDCard()"><a>Format SD Card</a></li>
+                        </ul>
+                    </span>
+                </h3>
+            </div>
+            <div class="panel-body">
+                <div class="center-block" ng-hide="lsusb">
+                    <img class="center-block" src="img/throbber.gif">
+                    <br/>
+                </div>
+                <div class="alert well-sm alert-info ng-hide" ng-show="formattingSDCard">Formatting SD Card, please wait. <img src="img/throbber.gif"></div>
+                <div class="alert well-sm alert-success ng-hide" ng-show="formatSuccess">Successfully formatted SD Card</div>
+                <pre class="scrollable-pre" ng-show="lsusb">{{ lsusb }}</pre>
+                <p>
+                    <textarea class="form-control" rows="8" ng-model="fstab"></textarea>
+                </p>
+                <p class="alert well-sm alert-success" ng-show="fstabSaved">Fstab saved successfully</p>
+                <button type="submit" class="btn btn-default" ng-click="saveFstab()">Save Fstab</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="row">
+    <div class="col-md-6">
+        <div class="panel panel-default" ng-controller="AdvancedUpgradeController">
+            <div class="panel-heading">
+                <h3 class="panel-title">Firmware Upgrade (Current version: {{ firmwareVersion }})</h3>
+            </div>
+            <div class="panel-body">
+                <div ng-hide="upgradeFound">
+                    <p class="text-center text-danger" ng-show="error">{{ error }}</p>
+                    <button class="btn btn-default center-block" ng-click="checkForUpgrade()" ng-hide="loading">Check for Upgrades</button>
+                    <img class="center-block" src="img/throbber.gif" ng-show="loading">
+                    <p>
+                        <hr>
+                    </p>
+                    <p ng-if="device == 'tetra'">
+                        Warning: Firmware upgrades replace all data on the device. Please ensure any important non-system data has been backed up to external storage.
+                    </p>
+                    <p ng-if="device == 'nano'">
+                        Warning: Firmware upgrades replace all data not stored on the SD card. Please ensure any important non-system data has been backed up to external storage.
+                    </p>
+                    <p>
+                        Please stop any unnecessary services and modules before upgrading. Restarting the WiFi Pineapple without starting additional services and modules is recommended to ensure extra processes have been halted properly.
+                    </p>
+                    <p>
+                        Upgrading firmware should only be done while using a stable power source. An Ethernet connection to the WiFi Pineapple is recommended for this process.
+                    </p>
+                    <p>
+                        Once the firmware upgrade has completed the WiFi Pineapple will reboot into an initial setup state. This process will take several minutes. Do not interrupt the upgrade process by unplugging power or closing the web interface as this may result in a soft-brick state.
+                    </p>
+                    <p>
+                        For recovery or manual upgrade instructions and help please visit <a href="https://www.wifipineapple.com/?flashing">https://www.wifipineapple.com/?flashing</a>.
+                    </p>
+                </div>
+                <div ng-show="upgradeFound && !downloading && !downloaded">
+                    <p>
+                        A new Firmware (version {{upgradeData['version']}}) is available.
+                    </p>
+                    <h4>Changelog:</h4>
+                    <p ng-bind-html="upgradeData['changelog'] | rawHTML">
+                        Changelog:
+                    </p>
+                    <p>
+                        <button class="btn btn-default center-block" ng-click="downloadUpgrade()">Perform Upgrade</button>
+                    </p>
+                </div>
+                <div class="text-center" ng-show="downloading">
+                    Please wait while the firmware is being downloaded.
+                    <div class="progress">
+                        <div class="progress-bar progress-bar-striped active" role="progressbar" aria-valuenow="downloadPercentage" aria-valuemin="0" aria-valuemax="100" style="width: {{ downloadPercentage }}%">
+                            <span class="sr-only">{{downloadPercentage}}% Complete</span>
+                        </div>
+                    </div>
+                </div>
+                <div class="text-center" ng-show="downloaded">
+                    <p>Download completed. Please wait while the firmware is being flashed.</p>
+                    <p>
+                        <img src="img/throbber.gif">
+                    </p>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="col-md-6" ng-controller="AdvancedCSSController">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">CSS</h3>
+            </div>
+            <div class="panel-body">
+                <p>
+                    <textarea class="form-control" rows="13" ng-model="css"></textarea>
+                </p>
+                <p class="alert well-sm alert-success" ng-show="cssSaved">CSS saved successfully</p>
+                <button type="submit" class="btn btn-default" ng-click="saveCSS()">Save CSS</button>
+            </div>
+        </div>
+    </div>
+</div>
+<div class="row">
+    <div ng-controller="APITokenController">
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <h3 class="panel-title">
+                        Manage API Tokens
+                        <span class="pull-right"><button class="btn btn-default btn-xs btn-fixed-length" ng-click="getApiTokens();">Refresh</button></span>
+                    </h3>
+                </div>
+                <table class="table table-hover table-responsive table-condensed table-layout-fixed" ng-show="apiTokens.length">
+                    <thead>
+                        <th>ID</th>
+                        <th>Name</th>
+                        <th>Token</th>
+                    </thead>
+                    <tbody>
+                        <tr ng-repeat="apiToken in apiTokens">
+                            <td class="col-md-1">{{ apiToken.rowid }}</td>
+                            <td class="col-md-3">{{ apiToken.name }}</td>
+                            <td class="col-md-5 truncated" ng-click="selectOnClick($event);">{{ apiToken.token }}</td>
+                            <td class="col-md-3"><span class="pull-right"><button tokenid="{{ apiToken.rowid }}" class="btn btn-danger btn-sm" ng-click="revokeApiToken($event);">Revoke</button></span></td>
+                        </tr>
+                    </tbody>
+                </table>
+
+                <div class="panel-body text-center" ng-hide="apiTokens.length">
+                    <span class="text-info">No API Tokens</span>
+                </div>
+            </div>
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <h3 class="panel-title">
+                        Generate New Token
+                    </h3>
+                </div>
+                <div class="panel-body">
+                    <form class="form-inline" role="form" ng-submit="genApiToken()" novalidate>
+                        <div class="form-group">
+                            <div class="input-group">
+                                <span class="input-group-addon">Token Name</span>
+                                <input name="tokenName" type="text" class="form-control" id="tokenName" ng-model="newToken.name">
+                            </div>
+                        </div>
+                        <button type="submit" class="btn btn-default">Generate</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>

+ 12 - 0
src/pineapple/modules/Advanced/module.info

@@ -0,0 +1,12 @@
+{
+    "author": "Hak5",
+    "description": "Information on system resources and firmware.",
+    "devices": [
+        "nano",
+        "tetra"
+    ],
+    "system": true,
+    "title": "Advanced",
+    "version": "1.0",
+    "index": 12
+}

+ 28 - 0
src/pineapple/modules/Advanced/module_icon.svg

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 256 256" style="enable-background:new 0 0 256 256;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#808080;}
+</style>
+<g>
+	<path class="st0" d="M217.8,127.8c1.4-0.5,2.7-1,4-1.6l7.6,6.7c3.3-1.8,6.3-4.1,9-6.6l-4-9.3c1.9-2.1,3.6-4.5,5-7l10.1,1
+		c1.6-3.4,2.7-6.9,3.5-10.6l-8.7-5.1c0.3-2.8,0.4-5.7,0-8.6l8.7-5.1c-0.4-1.8-0.8-3.6-1.4-5.4c-0.6-1.8-1.3-3.5-2-5.2l-10,1
+		c-1.4-2.5-3.2-4.8-5.1-6.9l4-9.3c-2.8-2.5-5.8-4.7-9-6.6l-7.6,6.7c-2.6-1.2-5.3-2.1-8.2-2.7l-2.2-9.9c-3.7-0.4-7.4-0.5-11.1,0
+		l-2.2,9.9c-1.4,0.3-2.8,0.6-4.2,1.1c-1.4,0.5-2.7,1-4,1.6l-7.6-6.7c-3.3,1.8-6.3,4.1-9,6.6l4,9.3c-1.9,2.1-3.6,4.5-5,7l-10.1-1
+		c-1.6,3.4-2.7,6.9-3.5,10.6l8.7,5.1c-0.3,2.8-0.4,5.7,0,8.6l-8.7,5.1c0.4,1.8,0.8,3.6,1.4,5.4c0.6,1.8,1.3,3.5,2,5.2l10-1
+		c1.4,2.5,3.2,4.9,5.1,7l-4,9.3c2.8,2.5,5.8,4.7,9,6.6l7.6-6.7c2.6,1.2,5.3,2.1,8.2,2.7l2.2,9.9c3.7,0.4,7.4,0.5,11.1,0l2.2-9.9
+		C215,128.6,216.4,128.2,217.8,127.8z M192.9,95.2c-2.3-7.2,1.6-14.9,8.8-17.2c7.2-2.3,14.9,1.6,17.2,8.8c2.3,7.2-1.6,14.9-8.8,17.2
+		C202.9,106.3,195.2,102.4,192.9,95.2z"/>
+	<path class="st0" d="M104.5,211.1c2.5-0.6,5-1.2,7.4-2c2.5-0.8,4.9-1.7,7.2-2.7l-2.1-13.8c3.4-1.7,6.7-3.6,9.7-5.8l11.2,8.4
+		c4-3.2,7.7-6.7,11.1-10.6l-7.9-11.6c2.3-3,4.4-6.2,6.2-9.5l13.8,2.8c2.2-4.6,4.1-9.4,5.4-14.4l-12.2-7c0.8-3.7,1.3-7.4,1.5-11.2
+		l13.6-3.5c0-5.1-0.4-10.2-1.4-15.3l-14-1c-0.4-1.8-0.9-3.7-1.5-5.5c-0.6-1.8-1.3-3.6-2-5.3l10.7-9c-2.2-4.7-4.9-9.1-7.9-13.2
+		l-13,5.2c-2.4-3-5-5.7-7.9-8.2l5.7-12.8c-4-3.2-8.3-6.1-12.9-8.5l-9.5,10.3c-3.4-1.6-7-3-10.6-4l-0.4-14c-5-1.1-10-1.8-15.2-2.1
+		l-4.1,13.4c-3.7,0-7.5,0.4-11.3,1l-6.4-12.4c-2.5,0.6-5,1.2-7.4,2c-2.5,0.8-4.9,1.7-7.2,2.7l2.1,13.8C52,69,48.7,71,45.7,73.2
+		l-11.2-8.4c-4,3.2-7.7,6.7-11.1,10.6l8,11.6c-2.3,3-4.4,6.1-6.2,9.5l-13.8-2.8C9.1,98.2,7.3,103,5.9,108l12.2,7
+		c-0.8,3.7-1.3,7.4-1.5,11.2L3,129.7c0,5.1,0.4,10.2,1.4,15.3l14,1c0.4,1.8,0.9,3.7,1.5,5.5c0.6,1.8,1.3,3.6,2,5.3l-10.7,9
+		c2.2,4.7,4.9,9.1,7.9,13.2l13-5.2c2.4,3,5,5.7,7.8,8.2l-5.7,12.8c4,3.2,8.3,6.1,12.9,8.5l9.5-10.3c3.4,1.6,7,3,10.6,4l0.4,14
+		c5,1.2,10,1.8,15.2,2.1l4.1-13.4c3.7,0,7.5-0.4,11.3-1L104.5,211.1z M82,116.9c7.2-2.3,14.9,1.6,17.2,8.8
+		c2.3,7.2-1.6,14.9-8.8,17.2c-7.2,2.3-14.9-1.6-17.2-8.8C70.9,126.9,74.8,119.2,82,116.9z"/>
+</g>
+</svg>

+ 116 - 0
src/pineapple/modules/Clients/api/module.php

@@ -0,0 +1,116 @@
+<?php namespace pineapple;
+
+class Clients extends SystemModule
+{
+    private $dbConnection;
+
+    public function __construct($request)
+    {
+        parent::__construct($request, __CLASS__);
+        $this->dbConnection = false;
+
+        $dbLocation = $this->uciGet("pineap.@config[0].hostapd_db_path");
+        if (file_exists($dbLocation)) {
+            $this->dbConnection = new DatabaseConnection($dbLocation);
+        }
+    }
+
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'getClientData':
+                $this->getClientData();
+                break;
+            case 'kickClient':
+                $this->kickClient();
+                break;
+        }
+    }
+
+    private function getLeases() {
+        $dhcpReport = array();
+        $leases = explode("\n", @file_get_contents('/var/dhcp.leases'));
+        if ($leases) {
+            foreach ($leases as $lease) {
+                $dhcpReport[explode(' ', $lease)[1]] = array_slice(explode(' ', $lease), 2, 2);
+            }
+        }
+        return $dhcpReport;
+    }
+
+    private function getARPData() {
+        $arpReport = array();
+        exec('cat /proc/net/arp | awk \'{ if ($1 != "IP") {printf "%s %s\n", $1, $4;}}\'', $arpEntries);
+        foreach ($arpEntries as $arpEntry) {
+            $arpEntryArray = explode(' ', $arpEntry);
+            $arpReport[$arpEntryArray[1]] = $arpEntryArray[0];
+        }
+        return $arpReport;
+    }
+
+    private function getSSIDData()
+    {
+        $ssidData = array();
+        $clientRows = $this->dbConnection->query("SELECT DISTINCT mac,ssid FROM log WHERE log_type=1 ORDER BY updated_at ASC;");
+        foreach ($clientRows as $row) {
+            $ssidData[strtolower($row['mac'])] = $row['ssid'];
+        }
+        return $ssidData;
+    }
+
+    private function getStations() {
+        $stationsReport = array();
+        exec('
+            iw dev wlan0 station dump |
+            awk \'{ if ($1 == "Station") { printf "%s ", $2; } else if ($1 == "inactive") {print $3;} }\'
+        ', $stations);
+        foreach ($stations as $_ => $station) {
+            if (empty($station)) {
+                continue;
+            }
+            $stationArray = explode(' ', $station);
+            $stationsReport[$stationArray[0]] = $stationArray[1];
+        }
+        exec('
+            iw dev wlan0-2 station dump |
+            awk \'{ if ($1 == "Station") { printf "%s ", $2; } else if ($1 == "inactive") {print $3;} }\'
+        ', $stations);
+        foreach ($stations as $_ => $station) {
+            if (empty($station)) {
+                continue;
+            }
+            $stationArray = explode(' ', $station);
+            $stationsReport[$stationArray[0]] = $stationArray[1];
+        }
+        return $stationsReport;
+    }
+
+    private function getClientData()
+    {
+        $connectedClients = array();
+        $stationData = $this->getStations();
+        $dhcpData = $this->getLeases();
+        $arpData = $this->getARPData();
+        $ssidData = $this->getSSIDData();
+        foreach ($stationData as $mac => $signal) {
+            $client = array();
+            $client['mac'] = $mac;
+            $client['ip'] = $arpData[$mac];
+            $client['ssid'] = $ssidData[$mac];
+            $client['host'] = $dhcpData[$mac][1];
+            array_push($connectedClients, $client);
+        }
+        $this->response = array(
+            'clients' => $connectedClients
+        );
+    }
+
+    private function kickClient()
+    {
+        exec("hostapd_cli -i wlan0 deauthenticate {$this->request->mac}");
+        exec("hostapd_cli -i wlan0 disassociate {$this->request->mac}");
+        exec("hostapd_cli -i wlan0-2 deauthenticate {$this->request->mac}");
+        exec("hostapd_cli -i wlan0-2 disassociate {$this->request->mac}");
+        $this->response = array('success' => true);
+    }
+}

+ 32 - 0
src/pineapple/modules/Clients/js/module.js

@@ -0,0 +1,32 @@
+registerController("ClientsController", ['$api', '$scope', '$timeout', function($api, $scope, $timeout){
+    $scope.clients  = [];
+
+    $scope.getClientData = function(){
+        $api.request({
+            module: "Clients",
+            action: "getClientData"
+        }, function(response) {
+            $scope.parseClients(response.clients);
+        });
+    };
+
+    $scope.parseClients = function($clients) {
+        $scope.clients = $clients;
+    };
+
+    $scope.kickClient = function(client){
+        $api.request({
+            module: "Clients",
+            action: "kickClient",
+            mac: client.mac
+        }, function(){
+            client['kicking'] = true;
+            $timeout(function() {
+                client['kicking'] = false;
+                $scope.getClientData();
+            }, 3000);
+        });
+    };
+
+    $scope.getClientData();
+}]);

+ 53 - 0
src/pineapple/modules/Clients/module.html

@@ -0,0 +1,53 @@
+<div class="row" ng-controller="ClientsController">
+    <div class="col-md-12">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">Clients <button class="btn btn-default btn-xs btn-fixed-length pull-right" ng-click="getClientData()">Refresh</button></h3>
+            </div>
+            <div class="table-responsive table-dropdown" ng-show="clients.length">
+                <table class="table">
+                    <thead>
+                    <tr>
+                        <th>MAC Address</th>
+                        <th>IP Address</th>
+                        <th>SSID</th>
+                        <th>Hostname</th>
+                        <th>Kick Client</th>
+                    </tr>
+                    </thead>
+                    <tbody>
+                    <tr ng-repeat="client in clients">
+                        <td>
+                            <hook-button hook="mac" content="client['mac'].toUpperCase()" probes="true" client="true"></hook-button>
+                            <span class="autoselect uppercase">{{ client['mac'] }}</span>
+                        </td>
+                        <td>
+                            <span ng-class="client['ip'] === undefined ? 'text-info' : 'autoselect'">
+                                {{ client['ip'] == null ? "No IP" : client['ip'] }}
+                            </span>
+                        </td>
+                        <td>
+                            <hook-button ng-if="client['ssid']" hook="ssid" content="client['ssid']" client="true"></hook-button>
+                            <span ng-class="client['ssid'] === undefined ? 'text-info' : 'autoselect'" >
+                                {{ client['ssid'] == null ? 'No SSID' : client['ssid'] }}
+                            </span>
+                        </td>
+                        <td>
+                            <span ng-class="client['host'] === undefined ? 'text-info' : 'autoselect'" >
+                                {{ client['host'] == null ? 'No Hostname' : client['host'] }}
+                            </span>
+                        </td>
+                        <td>
+                            <button ng-hide="client['kicking']" type="button" class="btn btn-default" ng-click="kickClient(client)">Kick</button>
+                            <img ng-show="client['kicking']" src='img/throbber.gif'>
+                        </td>
+                    </tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="panel-body" ng-hide="clients.length">
+                No clients found.
+            </div>
+        </div>
+    </div>
+</div>

+ 12 - 0
src/pineapple/modules/Clients/module.info

@@ -0,0 +1,12 @@
+{
+    "author": "Hak5",
+    "description": "View and manage connected clients.",
+    "devices": [
+        "nano",
+        "tetra"
+    ],
+    "system": true,
+    "title": "Clients",
+    "version": "1.0",
+    "index": 3
+}

+ 16 - 0
src/pineapple/modules/Clients/module_icon.svg

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 256 256" style="enable-background:new 0 0 256 256;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#808080;}
+</style>
+<g>
+	<circle class="st0" cx="79.7" cy="83.7" r="45.9"/>
+	<path class="st0" d="M109.9,127.1c-8.6,6-18.9,9.5-30.2,9.5c-11.2,0-21.6-3.5-30.2-9.5C22.2,142.2,3,177.4,3,218.3h153.5
+		C156.5,177.4,137.3,142.2,109.9,127.1z"/>
+	<circle class="st0" cx="191.7" cy="110.7" r="36.7"/>
+	<path class="st0" d="M215.8,145.4c-6.8,4.8-15.1,7.6-24.1,7.6c-9,0-17.3-2.8-24.1-7.6c-6.6,3.6-12.5,8.7-17.7,14.9
+		c8.7,16.9,13.6,36.9,13.6,58H253C253,185.6,237.7,157.5,215.8,145.4z"/>
+</g>
+</svg>

+ 44 - 0
src/pineapple/modules/Configuration/api/landingpage_index.php

@@ -0,0 +1,44 @@
+<?php
+
+function increment_browser($browser)
+{
+    try {
+        $sqlite = new \SQLite3('/tmp/landingpage.db');
+    } catch (Exception $e) {
+        return false;
+    }
+    $sqlite->exec('CREATE TABLE IF NOT EXISTS user_agents  (browser TEXT NOT NULL);');
+    $statement = $sqlite->prepare('INSERT INTO user_agents (browser) VALUES(:browser);');
+    $statement->bindValue(':browser', $browser, SQLITE3_TEXT);
+    try {
+        $ret = $statement->execute();
+    } catch (Exception $e) {
+        return false;
+    }
+    return $ret;
+}
+
+function identifyUserAgent($userAgent)
+{
+    if (preg_match('/^Mozilla/', $userAgent)) {
+        if (preg_match('/Chrome\/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/', $userAgent)) {
+            increment_browser('chrome');
+        } elseif (preg_match('/Safari\/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/', $userAgent)) {
+            increment_browser('safari');
+        } elseif (strpos($userAgent, 'MSIE ') || preg_match('/Trident\/[0-9]/', $userAgent)) {
+            increment_browser('internet_explorer');
+        } elseif (preg_match('/Firefox\/[0-9]{1,3}\.[0-9]{1,3}/', $userAgent)) {
+            increment_browser('firefox');
+        } else {
+            increment_browser('other');
+        }
+    } elseif (preg_match('/^Opera\/[0-9]{1,3}\.[0-9]/', $userAgent)) {
+        increment_browser('opera');
+    } else {
+        increment_browser('other');
+    }
+}
+
+identifyUserAgent($_SERVER['HTTP_USER_AGENT']);
+
+require_once('/etc/pineapple/landingpage.php');

+ 210 - 0
src/pineapple/modules/Configuration/api/module.php

@@ -0,0 +1,210 @@
+<?php namespace pineapple;
+
+class Configuration extends SystemModule
+{
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'getCurrentTimeZone':
+                $this->getCurrentTimeZone();
+                break;
+
+            case 'getLandingPageData':
+                $this->getLandingPageData();
+                break;
+
+            case 'saveLandingPage':
+                $this->saveLandingPageData();
+                break;
+
+            case 'changePass':
+                $this->changePass();
+                break;
+
+            case 'changeTimeZone':
+                $this->changeTimeZone();
+                break;
+
+            case 'resetPineapple':
+                $this->resetPineapple();
+                break;
+
+            case 'haltPineapple':
+                $this->haltPineapple();
+                break;
+
+            case 'rebootPineapple':
+                $this->rebootPineapple();
+                break;
+
+            case 'getLandingPageStatus':
+                $this->getLandingPageStatus();
+                break;
+
+            case 'getAutoStartStatus':
+                $this->getAutoStartStatus();
+                break;
+
+            case 'enableLandingPage':
+                $this->enableLandingPage();
+                break;
+
+            case 'disableLandingPage':
+                $this->disableLandingPage();
+                break;
+
+            case 'enableAutoStart':
+                $this->enableAutoStart();
+                break;
+
+            case 'disableAutoStart':
+                $this->disableAutoStart();
+                break;
+
+            case 'getButtonScript':
+                $this->getButtonScript();
+                break;
+
+            case 'saveButtonScript':
+                $this->saveButtonScript();
+                break;
+
+            case 'getDevice':
+                $this->getDeviceName();
+                break;
+        }
+    }
+
+    private function haltPineapple()
+    {
+        $this->execBackground("sync && led all off && halt");
+        $this->response = array("success" => true);
+    }
+
+    private function rebootPineapple()
+    {
+        $this->execBackground("reboot");
+        $this->response = array("success" => true);
+    }
+
+    private function resetPineapple()
+    {
+        if ($this->getDevice() === "nano") {
+            $this->execBackground("mtd -r erase rootfs_data");
+        } else if ($this->getDevice() === "tetra") {
+            $this->execBackground("jffs2reset -y && reboot &");
+        }
+        $this->response = array("success" => true);
+    }
+
+    private function getCurrentTimeZone()
+    {
+        $currentTimeZone = exec('date +%Z%z');
+        $this->response = array("currentTimeZone" => $currentTimeZone);
+    }
+
+    private function changeTimeZone()
+    {
+        $timeZone = $this->request->timeZone;
+        file_put_contents('/etc/TZ', $timeZone);
+        $this->uciSet('system.@system[0].timezone', $timeZone);
+        $this->response = array("success" => true);
+    }
+
+    private function getLandingPageData()
+    {
+        $landingPage = file_get_contents('/etc/pineapple/landingpage.php');
+        $this->response = array("landingPage" => $landingPage);
+    }
+
+    private function getLandingPageStatus()
+    {
+        if (!empty(exec("iptables -L -vt nat | grep 'www to:.*:80'"))) {
+            $this->response = array("enabled" => true);
+            return;
+        }
+        $this->response = array("enabled" => false);
+    }
+
+    private function enableLandingPage()
+    {
+        exec('iptables -t nat -A PREROUTING -p tcp --dport 80 -j DNAT --to-destination $(uci get network.lan.ipaddr):80');
+        exec('iptables -t nat -A POSTROUTING -j MASQUERADE');
+        copy('/pineapple/modules/Configuration/api/landingpage_index.php', '/www/index.php');
+        $this->response = array("success" => true);
+    }
+
+    private function disableLandingPage()
+    {
+        @unlink('/www/index.php');
+        exec('iptables -t nat -D PREROUTING -p tcp --dport 80 -j DNAT --to-destination $(uci get network.lan.ipaddr):80');
+        $this->response = array("success" => true);
+    }
+
+    private function getAutoStartStatus()
+    {
+        if($this->uciGet("landingpage.@settings[0].autostart") == 1) {
+            $this->response = array("enabled" => true);
+        } else {
+            $this->response = array("enabled" => false);
+        }
+    }
+
+    private function enableAutoStart()
+    {
+        $this->uciSet("landingpage.@settings[0].autostart", "1");
+        $this->response = array("success" => true);
+    }
+
+    private function disableAutoStart()
+    {
+        $this->uciSet("landingpage.@settings[0].autostart", "0");
+        $this->response = array("success" => true);
+    }
+
+    private function saveLandingPageData()
+    {
+        if (file_put_contents('/etc/pineapple/landingpage.php', $this->request->landingPageData) !== false) {
+            $this->response = array("success" => true);
+        } else {
+            $this->error = "Error saving Landing Page.";
+        }
+    }
+
+    private function getButtonScript()
+    {
+        if (file_exists('/etc/pineapple/button_script')) {
+            $script = file_get_contents('/etc/pineapple/button_script');
+            $this->response = array("buttonScript" => $script);
+        } else {
+            $this->error = "The button script does not exist.";
+        }
+    }
+
+    private function saveButtonScript()
+    {
+        if (file_exists('/etc/pineapple/button_script')) {
+            file_put_contents('/etc/pineapple/button_script', $this->request->buttonScript);
+            $this->response = array("success" => true);
+        } else {
+            $this->error = "The button script does not exist.";
+        }
+    }
+
+    private function getDeviceName()
+    {
+        $this->response = array("device" => $this->getDevice());
+    }
+
+    protected function changePass()
+    {
+        if ($this->request->newPassword === $this->request->newPasswordRepeat) {
+            if (parent::changePassword($this->request->oldPassword, $this->request->newPassword) === true) {
+                $this->response = array("success" => true);
+                return;
+            }
+        }
+
+        $this->response = array("success" => false);
+    }
+}

+ 284 - 0
src/pineapple/modules/Configuration/js/module.js

@@ -0,0 +1,284 @@
+registerController("ConfigurationGeneralController", ['$api', '$scope', '$timeout', function($api, $scope, $timeout) {
+	$scope.actionMessage = "";
+	$scope.currentTimeZone = "";
+	$scope.customOffset = "";
+	$scope.showTimeZoneSuccess = false;
+	$scope.oldPassword = "";
+	$scope.newPassword = "";
+	$scope.newPasswordRepeat = "";
+	$scope.showPasswordSuccess = false;
+	$scope.showPasswordError = false;
+	$scope.device = "";
+	$scope.resetMessage = "";
+
+    $api.request({
+        module: 'Configuration',
+        action: 'getDevice'
+    }, function(response){
+        $scope.device = response.device;
+    });
+
+	$scope.haltPineapple = (function() {
+		if (confirm("Are you sure you want to shutdown your WiFi Pineapple?")) {
+			$api.request({
+				module: "Configuration",
+				action: "haltPineapple"
+			}, function(response) {
+				if (response.success !== undefined) {
+					$scope.actionMessage = "Your WiFi Pineapple is now shutting down. Once the LED has turned off, it is safe to unplug.";
+					$timeout(function(){
+					    $scope.actionMessage = "";
+					}, 10000);
+				}
+			});
+		}
+	});
+
+	$scope.rebootPineapple = (function() {
+		if (confirm("Are you sure you want to reboot your WiFi Pineapple?")) {
+			$api.request({
+				module: "Configuration",
+				action: "rebootPineapple"
+			}, function(response) {
+				if (response.success !== undefined) {
+					$scope.actionMessage = "Your WiFi Pineapple is now rebooting. You may need to reconnect once it is done.";
+					$timeout(function(){
+					    $scope.actionMessage = "";
+					}, 10000);
+				}
+			});
+		}
+	});
+
+	$scope.resetPineapple = (function() {
+		if($scope.device === 'nano') {
+			$scope.resetMessage = "Are you sure you want to factory reset your WiFi Pineapple?\n\nThis will erase all data that has not been saved on the SD card.";
+		} else if($scope.device === 'tetra') {
+			$scope.resetMessage = "Are you sure you want to factory reset your WiFi Pineapple?\n\nThis will erase all data that has not been saved externally.";
+		}
+
+		if (confirm($scope.resetMessage)) {
+			$api.request({
+				module: "Configuration",
+				action: "resetPineapple"
+			}, function(response) {
+				if (response.success !== undefined) {
+					$scope.actionMessage = "Your WiFi Pineapple is now restoring to factory defaults. This can take a few minutes and you will be disconnected.";
+					$timeout(function(){
+					    $scope.actionMessage = "";
+					}, 10000);
+				}
+			});
+		}
+	});
+
+	$scope.changePassword = (function() {
+		$api.request({
+			module: 'Configuration',
+			action: 'changePass',
+			oldPassword: $scope.oldPassword,
+			newPassword: $scope.newPassword,
+			newPasswordRepeat: $scope.newPasswordRepeat
+		}, function(response) {
+			if (response.success === true) {
+				$scope.showPasswordSuccess = true;
+				$timeout(function(){
+				    $scope.showPasswordSuccess = false;
+				}, 2000);
+			} else {
+				$scope.showPasswordError = true;
+				$timeout(function(){
+				    $scope.showPasswordError = false;
+				}, 5000);
+			}
+			$scope.oldPassword = "";
+			$scope.newPassword = "";
+			$scope.newPasswordRepeat = "";
+		});
+	});
+
+	$scope.timeZones = [
+		{ value: 'GMT+12', description: "(GMT-12:00) Eniwetok, Kwajalein" },
+		{ value: 'GMT+11', description: "(GMT-11:00) Midway Island, Samoa" },
+		{ value: 'GMT+10', description: "(GMT-10) Hawaii" },
+		{ value: 'GMT+9',  description: "(GMT-9) Alaska" },
+		{ value: 'GMT+8',  description: "(GMT-8) Pacific Time (US & Canada)" },
+		{ value: 'GMT+7',  description: "(GMT-7) Mountain Time (US & Canada)" },
+		{ value: 'GMT+6',  description: "(GMT-6) Central Time (US & Canada), Mexico City" },
+		{ value: 'GMT+5',  description: "(GMT-5) Eastern Time (US & Canada), Bogota, Lima" },
+		{ value: 'GMT+4',  description: "(GMT-4) Atlantic Time (Canada), Caracas, La Paz" },
+		{ value: 'GMT+3',  description: "(GMT-3) Brazil, Buenos Aires, Georgetown" },
+		{ value: 'GMT+2',  description: "(GMT-2) MidAtlantic" },
+		{ value: 'GMT+1',  description: "(GMT-1) Azores, Cape Verde Islands" },
+		{ value: 'UTC',    description: "(UTC) Western Europe Time, London, Lisbon, Casablanca"},
+		{ value: 'GMT-1',  description: "(GMT+1) Brussels, Copenhagen, Madrid, Paris" },
+		{ value: 'GMT-2',  description: "(GMT+2) Kaliningrad, South Africa" },
+		{ value: 'GMT-3',  description: "(GMT+3) Baghdad, Riyadh, Moscow, St. Petersburg" },
+		{ value: 'GMT-4',  description: "(GMT+4) Abu Dhabi, Muscat, Baku, Tbilisi" },
+		{ value: 'GMT-5',  description: "(GMT+5) Ekaterinburg, Islamabad, Karachi, Tashkent" },
+		{ value: 'GMT-6',  description: "(GMT+6) -Almaty, Dhaka, Colombo" },
+		{ value: 'GMT-7',  description: "(GMT+7) Bangkok, Hanoi, Jakarta" },
+		{ value: 'GMT-8',  description: "(GMT+8) Beijing, Perth, Singapore, Hong Kong" },
+		{ value: 'GMT-9',  description: "(GMT+9) Tokyo, Seoul, Osaka, Sapporo, Yakutsk" },
+		{ value: 'GMT-10', description: "(GMT+10) Eastern Australia, Guam, Vladivostok" },
+		{ value: 'GMT-11', description: "(GMT+11) Magadan, Solomon Islands, New Caledonia" },
+		{ value: 'GMT-12', description: "(GMT+12) Auckland, Wellington, Fiji, Kamchatka" }
+	];
+
+
+	$scope.getCurrentTimeZone = (function() {
+		$api.request({
+			module: "Configuration",
+			action: "getCurrentTimeZone"
+		}, function(response) {
+			$scope.currentTimeZone = response.currentTimeZone;
+		});
+	});
+
+	$scope.changeTimezone = (function() {
+		var tmpTimeZone = $scope.selectedTimeZone.value;
+		if ($scope.customOffset.trim() !== "") {
+			tmpTimeZone = $scope.customOffset;
+		}
+		$api.request({
+			module: "Configuration",
+			action: "changeTimeZone",
+			timeZone: tmpTimeZone
+		}, function(response) {
+			if (response.success !== undefined) {
+				$scope.getCurrentTimeZone();
+				$scope.customOffset = "";
+				$scope.showTimeZoneSuccess = true;
+				$timeout(function(){
+					$scope.showTimeZoneSuccess = false;
+				}, 2000)
+			}
+		});
+	});
+
+	$scope.getCurrentTimeZone();
+}]);
+
+registerController('ConfigurationLandingPageController', ['$api', '$scope', '$timeout', function($api, $scope, $timeout) {
+	$scope.pageSaved = false;
+	$scope.landingPage = '';
+	$scope.landingPageStatus = 'Disabled';
+
+	$api.request({
+		module: 'Configuration',
+		action: 'getLandingPageData'
+	}, function(response) {
+		$scope.landingPage = response.landingPage;
+	});
+
+	$scope.saveLandingPage = (function() {
+		$api.request({
+			module: 'Configuration',
+			action: 'saveLandingPage',
+			landingPageData: $scope.landingPage
+		}, function(response) {
+            if (response.success === true) {
+                $scope.pageSaved = true;
+                $timeout(function(){
+                    $scope.pageSaved = false;
+                }, 2000);
+            }
+		});
+	});
+
+	$scope.getLandingPageStatus = (function() {
+		$api.request({
+			module: 'Configuration',
+			action: 'getLandingPageStatus'
+		}, function(response) {
+			if (response.error === undefined) {
+				if (response.enabled === true) {
+					$scope.landingPageStatus = 'Enabled';
+				} else {
+					$scope.landingPageStatus = 'Disabled';
+				}
+			}
+		});
+	});
+
+	$scope.toggleLandingPage = (function() {
+		var toggleAction = ($scope.landingPageStatus === 'Enabled') ? 'disableLandingPage' : 'enableLandingPage';
+		$api.request({
+			module: 'Configuration',
+			action: toggleAction
+		}, function(response) {
+			if (response.error === undefined) {
+				$scope.getLandingPageStatus();
+			}
+		});
+	});
+
+
+	$scope.getAutoStartStatus = (function() {
+		$api.request({
+			module: 'Configuration',
+			action: 'getAutoStartStatus'
+		}, function(response) {
+			if (response.error === undefined) {
+				if (response.enabled === true) {
+					$scope.autoStartStatus = 'Enabled';
+				} else {
+					$scope.autoStartStatus = 'Disabled';
+				}
+			}
+		});
+	});
+
+	$scope.toggleAutoStart = (function() {
+		var toggleAction = ($scope.autoStartStatus === 'Enabled') ? 'disableAutoStart' : 'enableAutoStart';
+		$api.request({
+			module: 'Configuration',
+			action: toggleAction
+		}, function(response) {
+			if (response.error === undefined) {
+				$scope.getAutoStartStatus();
+			}
+		});
+	});
+
+
+	$scope.getLandingPageStatus();
+	$scope.getAutoStartStatus();
+}]);
+
+registerController('ButtonScriptController', ['$api', '$scope', '$timeout', function($api, $scope, $timeout) {
+	$scope.buttonScript = "";
+	$scope.scriptError = '';
+	$scope.scriptSaved = false;
+
+	$scope.getButtonScript = (function() {
+		$api.request({
+			module: 'Configuration',
+			action: 'getButtonScript'
+		}, function(response) {
+			if (response.error === undefined) {
+				$scope.buttonScript = response.buttonScript;
+			} else {
+				$scope.scriptError = response.error;
+			}
+		});
+	});
+	$scope.getButtonScript();
+
+	$scope.saveButtonScript = (function() {
+		$api.request({
+			module: 'Configuration',
+			action: 'saveButtonScript',
+			buttonScript: $scope.buttonScript
+		}, function(response) {
+			if (response.error === undefined) {
+				$scope.scriptSaved = true;
+				$timeout(function(){
+                    $scope.scriptSaved = false;
+                }, 2000);
+			} else {
+				$scope.scriptError = response.error;
+			}
+		});
+	});
+}]);

+ 136 - 0
src/pineapple/modules/Configuration/module.html

@@ -0,0 +1,136 @@
+<div class="row">
+    <div class="col-md-6">
+        <div class="panel panel-default" ng-controller="ConfigurationGeneralController">
+            <div class="panel-heading">
+                <h3 class="panel-title">General
+                    <span class="dropdown">
+                        <button class="btn btn-xs btn-default dropdown-toggle" type="button" id="generalDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <span class="caret"></span>
+                        </button>
+                        <ul class="dropdown-menu" aria-labelledby="generalDropdown">
+                            <li ng-click="haltPineapple()"><a>Shutdown Pineapple</a></li>
+                            <li ng-click="rebootPineapple()"><a>Reboot Pineapple</a></li>
+                            <li ng-click="resetPineapple()"><a>Factory Reset Pineapple</a></li>
+                        </ul>
+                    </span>
+                </h3>
+            </div>
+            <div class="panel-body">
+                <div class="col-sm-10">
+                    <div class="alert well-sm alert-success" ng-show="actionMessage.length != 0">{{ actionMessage }}</div>
+                    <div class="row">
+                        <div class="input-group">
+                            <span class="input-group-addon">Current Time Zone</span>
+                            <input type="text" class="form-control" ng-model="currentTimeZone" disabled>
+                        </div>
+                    </div>
+                    <br/>
+                    <div class="row">
+                        <div class="input-group">
+                            <span class="input-group-addon">Change Time Zone</span>
+                            <select class="form-control" ng-init="selectedTimeZone = timeZones[12]" ng-model="selectedTimeZone" ng-options="timeZone.description for timeZone in timeZones"></select>
+                        </div>
+                    </div>
+                    <br/>
+                    <div class="row">
+                        <div class="input-group">
+                            <span class="input-group-addon">Custom Time Zone</span>
+                            <input type="text" class="form-control" placeholder="UTC+0030" ng-model="customOffset">
+                        </div>
+                    </div>
+                    <br/>
+                    <div class="row">
+                        <p class="alert well-sm alert-success" ng-show="showTimeZoneSuccess">Time zone changed successfully.</p>
+                        <div class="input-group">
+                            <button type="button" class="btn btn-default" ng-click="changeTimezone()">Save Time Zone</button>
+                        </div>
+                    </div>
+                    <br/><br/><br/>
+                    <div class="row">
+                        <div class="input-group">
+                            <span class="input-group-addon">Old Password</span>
+                            <input type="password" class="form-control" ng-model="oldPassword">
+                        </div>
+                    </div>
+                    <br/>
+                    <div class="row">
+                        <div class="input-group">
+                            <span class="input-group-addon">New Password</span>
+                            <input type="password" class="form-control" ng-model="newPassword">
+                        </div>
+                    </div>
+                    <br/>
+                    <div class="row">
+                        <div class="input-group">
+                            <span class="input-group-addon">Repeat Password</span>
+                            <input type="password" class="form-control" ng-model="newPasswordRepeat">
+                        </div>
+                    </div>
+                    <br/>
+                    <div class="row">
+                        <p class="alert well-sm alert-success" ng-show="showPasswordSuccess">Password changed successfully.</p>
+                        <p class="alert well-sm alert-danger" ng-show="showPasswordError">Please check the passwords entered are correct.</p>
+                        <div class="input-group">
+                            <button type="button" class="btn btn-default" ng-click="changePassword()">Change Password</button>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="col-md-6">
+        <div class="panel panel-default" ng-controller="ConfigurationLandingPageController">
+            <div class="panel-heading">
+                <h3 class="panel-title">Landing Page</h3>
+            </div>
+            <div class="panel-body">
+                <div class="row">
+                    <div class="col-md-6">
+                        <div class="input-group">
+                            <span class="input-group-addon">Landing Page: {{ landingPageStatus }}</span>
+                            <span class="input-group-btn">
+                                <button class="btn btn-default" type="button" ng-click="toggleLandingPage()">Switch</button>
+                            </span>
+                        </div>
+                    </div>
+                    <div class="col-md-6">
+                        <div class="input-group">
+                            <span class="input-group-addon">Auto Start: {{ autoStartStatus }}</span>
+                            <span class="input-group-btn">
+                                <button class="btn btn-default" type="button" ng-click="toggleAutoStart()">Switch</button>
+                            </span>
+                        </div>
+                    </div>
+                </div>
+                <br/>
+                <form class="form-horizontal">
+                    <textarea class="form-control" rows="18" ng-model="landingPage"></textarea>
+                    <br/>
+                    <p class="alert well-sm alert-success" ng-show="pageSaved">Landing Page saved successfully</p>
+                    <button type="submit" class="btn btn-default" ng-click="saveLandingPage()">Save</button>
+                </form>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="row">
+    <div class="col-md-6" ng-controller="ButtonScriptController">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">Button Script</h3>
+            </div>
+            <div class="panel-body">
+                <form class="form-horizontal">
+                    <p class="text-muted text-sm">This script executes when the reset button is pressed for less than 5 seconds.</p>
+                    <textarea class="form-control" rows="10" ng-model="buttonScript"></textarea>
+                    <br/>
+                    <p class="alert well-sm alert-success" ng-show="scriptSaved">Button Script saved successfully</p>
+                    <p class="alert well-sm alert-danger" ng-show="scriptError">{{ scriptError }}</p>
+                    <button type="submit" class="btn btn-default" ng-click="saveButtonScript()">Save</button>
+                </form>
+            </div>
+        </div>
+    </div>
+</div>

+ 12 - 0
src/pineapple/modules/Configuration/module.info

@@ -0,0 +1,12 @@
+{
+    "author": "Hak5",
+    "description": "Manage general settings and the landing page.",
+    "devices": [
+        "nano",
+        "tetra"
+    ],
+    "system": true,
+    "title": "Configuration",
+    "version": "1.0",
+    "index": 11
+}

+ 13 - 0
src/pineapple/modules/Configuration/module_icon.svg

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 256 256" style="enable-background:new 0 0 256 256;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#808080;}
+</style>
+<path class="st0" d="M232.4,48c-0.1-0.2-0.3-0.3-0.4-0.5c-15.6-10.7-36.6-12.1-54.1-2.1c-18,10.4-27.2,30.2-25.1,49.6
+	C112.4,114.7,59,141,19,161.5C4.3,169-0.1,187.9,8.1,202.1C16.3,216.3,35,222,48.7,213l124.5-82.7c15.7,11.6,37.5,13.5,55.5,3.1
+	c17.5-10.1,26.7-29,25.2-47.9c0-0.2-0.1-0.4-0.2-0.6c-0.4-0.8-1.6-1.2-2.4-0.7L215.2,105c-2.5,1.4-5.5,0.6-7-1.9l-14.4-24.9
+	c-1.4-2.5-0.6-5.5,1.9-6.9l36.1-20.8C232.7,50,232.9,48.8,232.4,48L232.4,48z M49,178.5c4.8,8.3,2,19-6.3,23.8
+	c-8.3,4.8-19,2-23.8-6.4c-4.8-8.3-2-19,6.4-23.8S44.2,170.1,49,178.5L49,178.5z"/>
+</svg>

+ 105 - 0
src/pineapple/modules/Dashboard/api/module.php

@@ -0,0 +1,105 @@
+<?php namespace pineapple;
+
+require_once('/pineapple/api/DatabaseConnection.php');
+
+class Dashboard extends SystemModule
+{
+    private $dbConnection;
+    public function __construct($request)
+    {
+        parent::__construct($request, __CLASS__);
+        $this->dbConnection = false;
+        if (file_exists('/tmp/landingpage.db')) {
+            $this->dbConnection = new DatabaseConnection('/tmp/landingpage.db');
+        }
+    }
+
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'getOverviewData':
+                $this->getOverviewData();
+                break;
+
+            case 'getLandingPageData':
+                $this->getLandingPageData();
+                break;
+            
+            case 'getBulletins':
+                $this->getBulletins();
+                break;
+        }
+    }
+
+    private function getOverviewData()
+    {
+        $this->response = array(
+            "cpu" => $this->getCpu(),
+            "uptime" => $this->getUptime(),
+            "clients" => $this->getClients()
+        );
+    }
+
+    private function getCpu()
+    {
+        $loads = sys_getloadavg();
+        $load = round($loads[0]/2*100, 1);
+
+        if ($load > 100) {
+            return '100';
+        }
+
+        return $load;
+    }
+
+    private function getUptime()
+    {
+        $seconds = intval(explode('.', file_get_contents('/proc/uptime'))[0]);
+        $days = floor($seconds / (24 * 60 * 60));
+        $hours = floor(($seconds % (24 * 60 * 60)) / (60 * 60));
+        if ($days > 0) {
+            return $days . ($days == 1 ? " day, " : " days, ") . $hours . ($hours == 1 ? " hour" : " hours");
+        }
+        $minutes = floor(($seconds % (60 * 60)) / 60);
+        return $hours . ($hours == 1 ? " hour, " : " hours, ") . $minutes . ($minutes == 1 ? " minute" : " minutes");
+    }
+
+    private function getClients()
+    {
+        $clients = exec('iw dev wlan0 station dump | grep Station | wc -l');
+        return $clients;
+    }
+
+    private function getLandingPageData()
+    {
+        if ($this->dbConnection !== false) {
+            $stats = array();
+            $stats['Chrome'] = count($this->dbConnection->query('SELECT browser FROM user_agents WHERE browser=\'chrome\';'));
+            $stats['Safari'] = count($this->dbConnection->query('SELECT browser FROM user_agents WHERE browser=\'safari\';'));
+            $stats['Firefox'] = count($this->dbConnection->query('SELECT browser FROM user_agents WHERE browser=\'firefox\';'));
+            $stats['Opera'] = count($this->dbConnection->query('SELECT browser FROM user_agents WHERE browser=\'opera\';'));
+            $stats['Internet Explorer'] = count($this->dbConnection->query('SELECT browser FROM user_agents WHERE browser=\'internet_explorer\';'));
+            $stats['Other'] = count($this->dbConnection->query('SELECT browser FROM user_agents WHERE browser=\'other\';'));
+            $this->response = $stats;
+        } else {
+            $this->error = "A connection to the database is not established.";
+        }
+    }
+
+
+    private function getBulletins()
+    {
+
+        $device = $this->getDevice();
+        $bulletinData = @file_get_contents("https://www.wifipineapple.com/{$device}/bulletin");
+
+        if ($bulletinData !== false) {
+            $this->response = json_decode($bulletinData);
+            if (json_last_error() === JSON_ERROR_NONE) {
+                return;
+            }
+        }
+        
+        $this->error = "Error connecting to WiFiPineapple.com. Please check your connection.";
+    }
+}

+ 68 - 0
src/pineapple/modules/Dashboard/js/module.js

@@ -0,0 +1,68 @@
+registerController("DashboardOverviewController", ['$api', '$scope', '$interval', function($api, $scope, $interval) {
+	$scope.cpu = "";
+	$scope.uptime = "";
+	$scope.clients = "";
+	$scope.ssids = "";
+	$scope.newssids = "";
+
+	$scope.populateDashboard = (function() {
+		$api.request({
+			module: "Dashboard",
+			action: "getOverviewData"
+		}, function(response) {
+			$scope.cpu = response.cpu;
+			$scope.uptime = response.uptime;
+			$scope.clients = response.clients;
+		});
+		$api.request({
+			module: "PineAP",
+			action: "countSSIDs"
+		}, function(response) {
+			$scope.ssids = response.SSIDs;
+			$scope.newssids = response.newSSIDs;
+		});
+	});
+
+	$scope.populateInterval = $interval(function(){
+		$scope.populateDashboard();
+	}, 5000);
+
+	$scope.populateDashboard();
+	$scope.$on('$destroy', function() {
+    	$interval.cancel($scope.populateInterval);
+    });
+}]);
+
+registerController("DashboardLandingPageController", ['$api', '$scope', function($api, $scope){
+	$scope.browsers = [];
+
+	$api.request({
+		module: "Dashboard",
+		action: "getLandingPageData"
+	}, function(response){
+		if (response.error === undefined) {
+			$scope.browsers = response;
+		}
+	});
+}]);
+
+registerController("DashboardBulletinsController", ['$api', '$scope', function($api, $scope){
+	$scope.bulletins = [];
+
+	$scope.getBulletins = function() {
+		$scope.loading = true;
+
+		$api.request({
+			module: "Dashboard",
+			action: "getBulletins"
+		}, function(response){
+			$scope.loading = false;
+			if (response.error !== undefined) {
+				$scope.error = response.error;
+			} else {
+				$scope.bulletins = response;
+				$scope.error = false;
+			}
+		});
+	}
+}]);

+ 157 - 0
src/pineapple/modules/Dashboard/module.html

@@ -0,0 +1,157 @@
+<div class="row" ng-controller="DashboardOverviewController">
+
+    <div class="col-md-4">
+        <div class="panel panel-default">
+            <div class="panel-body">
+                <h2 style="text-align: center">{{ uptime }}</h2>
+                <p class="text-muted text-center">UPTIME</p>
+            </div>
+            <div class="panel-footer text-muted text-center">
+                {{ cpu }}% CPU USAGE
+            </div>
+        </div>
+    </div>
+
+    <div class="col-md-4">
+        <div class="panel panel-default">
+            <div class="panel-body">
+                <a href="/#!/modules/Clients" style="color: black;">
+                    <h2 style="text-align: center">{{ clients }}</h2>
+                    <p class="text-muted text-center">CLIENTS CONNECTED</p>
+                </a>
+            </div>
+            <div class="panel-footer text-muted text-center">
+                &nbsp;
+            </div>
+        </div>
+    </div>
+
+    <div class="col-md-4">
+        <div class="panel panel-default">
+            <div class="panel-body">
+                <a href="/#!/modules/PineAP" style="color: black;">
+                    <h2 style="text-align: center">{{ ssids }}</h2>
+                    <p class="text-muted text-center">SSIDS IN POOL</p>
+                </a>
+            </div>
+            <div class="panel-footer text-muted text-center">
+                {{ newssids }} SSIDS ADDED THIS SESSION
+            </div>
+        </div>
+    </div>
+
+</div>
+
+<div class="row">
+
+    <div class="col-md-7">
+        <div class="panel panel-default" ng-controller="DashboardLandingPageController">
+            <div class="panel-heading">
+                <h3 class="panel-title">Landing Page Browser Stats</h3>
+            </div>
+            <table class="table" ng-hide="browsers.length == 0">
+                <tr>
+                    <td>
+                    <img src="img/browser_chrome.png" alt="chrome">
+                    Chrome
+                    </td>
+                    <td class="text-right">{{ browsers['Chrome'] }}</td>
+                </tr>
+                <tr>
+                    <td>
+                    <img src="img/browser_ff.png" alt="Firefox">
+                    Firefox
+                    </td>
+                    <td class="text-right">{{ browsers['Firefox'] }}</td>
+                </tr>
+                <tr>
+                    <td>
+                    <img src="img/browser_ie.png" alt="MSIE">
+                    Internet Explorer
+                    </td>
+                    <td class="text-right">{{ browsers['Internet Explorer'] }}</td>
+                </tr>
+                <tr>
+                    <td>
+                    <img src="img/browser_opera.png" alt="opera">
+                    Opera
+                    </td>
+                    <td class="text-right">{{ browsers['Opera'] }}</td>
+                </tr>
+                <tr>
+                    <td>
+                    <img src="img/browser_safari.png" alt="Safari">
+                    Safari
+                    </td>
+                    <td class="text-right">{{ browsers['Safari'] }}</td>
+                </tr>
+                <tr>
+                    <td>
+                    Other
+                    </td>
+                    <td class="text-right">{{ browsers['Other'] }}</td>
+                </tr>
+            </table>
+
+            <div class="panel-body" ng-show="browsers.length == 0">
+                <span class="text-center"><i>No Landing Page Browser Stats Available</i></span>
+            </div>
+        </div>
+
+        <div class="panel panel-default" ng-controller="DashboardBulletinsController">
+            <div class="panel-heading">
+                <h3 class="panel-title">Bulletins</h3>
+            </div>
+            <div class="panel-body">
+                <div ng-repeat="bulletin in bulletins" ng-show="bulletins.length">
+                    <h5><strong>{{ bulletin.title }}</strong><span class="pull-right">{{ bulletin.date }}</span></h5>
+                    <div ng-bind-html="bulletin.content | rawHTML"></div>
+                    <hr>
+                </div>
+
+                <div class="panel-body">
+                    <p class="text-center" ng-show="loading">
+                        <img src="img/throbber.gif">
+                    </p>
+                    <p class="text-center" ng-hide="(bulletins.length || loading)">
+                        <button class="btn btn-default" ng-click="getBulletins()">
+                            Load Bulletins from Hak5
+                        </button>
+                    </p>
+                    <p class="text-center text-danger" ng-show="error">
+                        {{ error }}
+                    </p>
+                </div>
+
+            </div>
+        </div>
+    </div>
+
+    <div class="col-md-5">
+        <div class="panel panel-default" ng-controller="NotificationController">
+            <div class="panel-heading">
+                <h3 class="panel-title">
+                    Notifications
+                    <span class="pull-right">
+                        <button ng-show="notifications.length" type="button" class="btn btn-default btn-xs btn-fixed-length pull-right" ng-click="clearNotifications();">
+                            Clear
+                        </button>
+                    </span>
+                </h3>
+            </div>
+            <table class="table" ng-show="notifications.length">
+                <tbody>
+                    <tr ng-repeat="notification in notifications">
+                        <td>{{ notification.message }}</td>
+                        <td>{{ notification.time | timesincedate }}</td>
+                    </tr>
+                </tbody>
+            </table>
+
+            <div class="panel-body" ng-hide="notifications.length">
+                <span class="text-center"><i>No Notifications</i></span>
+            </div>
+        </div>
+    </div>
+
+</div>

+ 12 - 0
src/pineapple/modules/Dashboard/module.info

@@ -0,0 +1,12 @@
+{
+    "author": "Hak5",
+    "description": "At-a-glance view of the WiFi Pineapple.",
+    "devices": [
+        "nano",
+        "tetra"
+    ],
+    "system": true,
+    "title": "Dashboard",
+    "version": "1.0",
+    "index": 1
+}

+ 15 - 0
src/pineapple/modules/Dashboard/module_icon.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 256 256" style="enable-background:new 0 0 256 256;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#808080;}
+</style>
+<g>
+	<path class="st0" d="M147.7,151.3c-5.8-3.5-12.5-5.5-19.7-5.5c-21,0-38,17-38,38c0,4.7,0.9,9.3,2.5,13.5h71.1
+		c1.6-4.2,2.5-8.7,2.5-13.5c0-5.3-1.1-10.3-3-14.9l35.5-46.2L147.7,151.3z"/>
+	<path class="st0" d="M128,58.8c-68.9,0-125,56.1-125,125c0,4.6,0.3,9.1,0.7,13.5h17.6c-0.6-4.4-0.9-8.9-0.9-13.5
+		c0-59.3,48.2-107.5,107.5-107.5c59.3,0,107.5,48.2,107.5,107.5c0,4.6-0.3,9.1-0.9,13.5h17.6c0.5-4.4,0.7-8.9,0.7-13.5
+		C253,114.8,196.9,58.8,128,58.8z"/>
+</g>
+</svg>

+ 226 - 0
src/pineapple/modules/Filters/api/module.php

@@ -0,0 +1,226 @@
+<?php namespace pineapple;
+
+class Filters extends SystemModule
+{
+    private $dbConnection;
+    public function __construct($request)
+    {
+        parent::__construct($request, __CLASS__);
+        $this->dbConnection = false;
+        $dbPath = '/etc/pineapple/filters.db';
+        if (file_exists($dbPath)) {
+            $this->dbConnection = new DatabaseConnection($dbPath);
+        }
+    }
+
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'getClientData':
+                $this->getClientData();
+                break;
+
+            case 'getSSIDData':
+                $this->getSSIDData();
+                break;
+
+            case 'toggleClientMode':
+                $this->toggleClientMode();
+                break;
+
+            case 'toggleSSIDMode':
+                $this->toggleSSIDMode();
+                break;
+
+            case 'addClient':
+                $this->addClient();
+                break;
+
+            case 'addClients':
+                $this->addClients();
+                break;
+
+            case 'addSSID':
+                $this->addSSID();
+                break;
+
+            case 'removeClient':
+                $this->removeClient();
+                break;
+
+            case 'removeSSID':
+                $this->removeSSID();
+                break;
+
+            case 'removeSSIDs':
+                $this->removeSSIDs();
+                break;
+
+            case 'removeClients':
+                $this->removeClients();
+                break;
+        }
+    }
+
+    private function getSSIDMode()
+    {
+        if (exec("hostapd_cli -i wlan0 karma_get_black_white") === "WHITE") {
+            return "Allow";
+        } else {
+            return "Deny";
+        }
+    }
+
+    private function getClientMode()
+    {
+        if (exec("hostapd_cli -i wlan0 karma_get_mac_black_white") === "WHITE") {
+            return "Allow";
+        } else {
+            return "Deny";
+        }
+    }
+
+    private function getSSIDFilters()
+    {
+        $ssidFilters = "";
+        $rows = $this->dbConnection->query("SELECT * FROM ssid_filter_list;");
+        if (!isset($rows['databaseQueryError'])) {
+            foreach ($rows as $row) {
+                $ssidFilters .= "${row['ssid']}\n";
+            }
+        }
+        return $ssidFilters;
+    }
+
+    private function getClientFilters()
+    {
+        $clientFilters = "";
+        $rows = $this->dbConnection->query("SELECT * FROM mac_filter_list;");
+        if (!isset($rows['databaseQueryError'])) {
+            foreach ($rows as $row) {
+                $clientFilters .= "${row['mac']}\n";
+            }
+        }
+        return $clientFilters;
+    }
+
+    private function toggleClientMode()
+    {
+        if ($this->request->mode === "Allow") {
+            exec("pineap /tmp/pineap.conf mac_filter white");
+            $this->uciSet('pineap.@config[0].mac_filter', 'white');
+        } else {
+            exec("pineap /tmp/pineap.conf mac_filter black");
+            $this->uciSet('pineap.@config[0].mac_filter', 'black');
+        }
+    }
+
+    private function toggleSSIDMode()
+    {
+        if ($this->request->mode === "Allow") {
+            $this->uciSet('pineap.@config[0].ssid_filter', 'white');
+            exec("pineap /tmp/pineap.conf ssid_filter white");
+        } else {
+            $this->uciSet('pineap.@config[0].ssid_filter', 'black');
+            exec("pineap /tmp/pineap.conf ssid_filter black");
+        }
+    }
+
+    private function getClientData()
+    {
+        $mode = $this->getClientMode();
+        $filters = $this->getClientFilters();
+        $this->response = array("mode" => $mode, "clientFilters" => $filters);
+    }
+
+    private function getSSIDData()
+    {
+        $mode = $this->getSSIDMode();
+        $filters = $this->getSSIDFilters();
+        $this->response = array("mode" => $mode, "ssidFilters" => $filters);
+    }
+
+
+    private function addSSID()
+    {
+        if (!empty($this->request->ssid)) {
+            $ssid_array = is_array($this->request->ssid) ? $this->request->ssid : array($this->request->ssid);
+            foreach ($ssid_array as $ssid) {
+                if (!empty($ssid)) {
+                    @$this->dbConnection->exec('INSERT INTO ssid_filter_list (ssid) VALUES (\'%s\')', $ssid);
+                }
+            }
+            $this->getSSIDData();
+        }
+    }
+
+    private function removeSSID()
+    {
+        if (isset($this->request->ssid)) {
+            $ssid = $this->request->ssid;
+            $this->dbConnection->exec('DELETE FROM ssid_filter_list WHERE ssid=\'%s\'', $ssid);
+            $this->getSSIDData();
+        }
+    }
+
+    private function removeSSIDs()
+    {
+        if (isset($this->request->ssids) && is_array($this->request->ssids)) {
+            foreach ($this->request->ssids as $ssid) {
+                if (!empty($ssid)) {
+                    $this->dbConnection->exec('DELETE FROM ssid_filter_list WHERE ssid=\'%s\'', $ssid);
+                }
+            }
+        }
+        $this->getSSIDData();
+    }
+
+    private function addClient()
+    {
+        if (!empty($this->request->mac)) {
+            $mac_array = is_array($this->request->mac) ? $this->request->mac : array($this->request->mac);
+            foreach ($mac_array as $mac) {
+                if (!empty($mac) && $mac != '00:00:00:00:00:00') {
+                    $mac = strtoupper(trim($mac));
+                        @$this->dbConnection->exec('INSERT INTO mac_filter_list (mac) VALUES (\'%s\')', $mac);
+                }
+            }
+            $this->getClientData();
+        }
+    }
+
+    private function addClients()
+    {
+        if (isset($this->request->clients) && is_array($this->request->clients)) {
+            foreach ($this->request->clients as $client) {
+                if (!empty($client) && $client != '00:00:00:00:00:00') {
+                    $mac = strtoupper(trim($client));
+                    @$this->dbConnection->exec('INSERT INTO mac_filter_list (mac) VALUES (\'%s\')', $mac);
+                }
+            }
+        }
+        $this->getClientData();
+    }
+
+    private function removeClient()
+    {
+        if (isset($this->request->mac)) {
+            $mac = strtoupper(trim($this->request->mac));
+            $this->dbConnection->exec('DELETE FROM mac_filter_list WHERE mac=\'%s\'', $mac);
+            $this->getClientData();
+        }
+    }
+
+    private function removeClients()
+    {
+        if (isset($this->request->clients) && is_array($this->request->clients)) {
+            foreach ($this->request->clients as $client) {
+                if (!empty($client)) {
+                    $mac = strtoupper(trim($client));
+                    $this->dbConnection->exec('DELETE FROM mac_filter_list WHERE mac=\'%s\'', $mac);
+                }
+            }
+        }
+        $this->getClientData();
+    }
+}

+ 141 - 0
src/pineapple/modules/Filters/js/module.js

@@ -0,0 +1,141 @@
+registerController('clientFilterController', ['$api', '$scope', function($api, $scope) {
+    $scope.mode = '';
+    $scope.mac = '';
+    $scope.clientFilters = '';
+
+    $scope.clearAll = (function() {
+        $api.request({
+            module: "Filters",
+            action: "removeClients",
+            clients: $scope.clientFilters.split("\n")
+        }, function(response) {
+            $scope.clientFilters = response.clientFilters;
+        });
+    });
+
+    $scope.toggleMode = (function() {
+        if ($scope.mode === 'Allow') {
+            $scope.mode = 'Deny';
+        } else {
+            $scope.mode = 'Allow';
+        }
+        $api.request({
+            module: 'Filters',
+            action: 'toggleClientMode',
+            mode: $scope.mode
+        });
+    });
+
+    $scope.addClient = (function() {
+        $api.request({
+            module: 'Filters',
+            action: 'addClient',
+            mac: convertMACAddress($scope.mac)
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.clientFilters = response.clientFilters;
+                $scope.mac = "";
+            }
+        });
+    });
+
+    $scope.removeClient = (function() {
+        $api.request({
+            module: 'Filters',
+            action: 'removeClient',
+            mac: convertMACAddress($scope.mac)
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.clientFilters = response.clientFilters;
+                $scope.mac = "";
+            }
+        });
+    });
+
+    $api.request({
+        module: 'Filters',
+        action: 'getClientData'
+    }, function(response) {
+        if (response.error === undefined) {
+            $scope.mode = response.mode;
+            $scope.clientFilters = response.clientFilters;
+        }
+    });
+}]);
+
+registerController('ssidFilterController', ['$api', '$scope', function($api, $scope) {
+    $scope.mode = '';
+    $scope.ssid = '';
+    $scope.ssidFilters = '';
+
+    $scope.clearAll = (function() {
+        $api.request({
+            module: "Filters",
+            action: "removeSSIDs",
+            ssids: $scope.ssidFilters.split("\n")
+        }, function(response) {
+            $scope.ssidFilters = response.ssidFilters;
+        });
+    });
+
+    $scope.toggleMode = (function() {
+        if ($scope.mode === 'Allow') {
+            $scope.mode = 'Deny';
+        } else {
+            $scope.mode = 'Allow';
+        }
+        $api.request({
+            module: 'Filters',
+            action: 'toggleSSIDMode',
+            mode: $scope.mode
+        });
+    });
+
+    $scope.addSSID = (function() {
+        $api.request({
+            module: 'Filters',
+            action: 'addSSID',
+            ssid: $scope.ssid
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.ssidFilters = response.ssidFilters;
+                $scope.ssid = "";
+            }
+        });
+    });
+
+    $scope.removeSSID = (function() {
+        $api.request({
+            module: 'Filters',
+            action: 'removeSSID',
+            ssid: $scope.ssid
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.ssidFilters = response.ssidFilters;
+                $scope.ssid = "";
+            }
+        });
+    });
+
+    $api.request({
+        module: 'Filters',
+        action: 'getSSIDData'
+    }, function(response) {
+        if (response.error === undefined) {
+            $scope.mode = response.mode;
+            $scope.ssidFilters = response.ssidFilters;
+        }
+    });
+}]);
+
+function getClientLineNumber(textarea) {
+    var lineNumber = textarea.value.substr(0, textarea.selectionStart).split("\n").length;
+    var mac = textarea.value.split("\n")[lineNumber-1].trim();
+    $("input[name='mac']").val(mac).trigger('input');
+}
+
+function getSSIDLineNumber(textarea) {
+    var lineNumber = textarea.value.substr(0, textarea.selectionStart).split("\n").length;
+    var ssid = textarea.value.split("\n")[lineNumber-1].trim();
+    $("input[name='ssid']").val(ssid).trigger('input');
+}

+ 93 - 0
src/pineapple/modules/Filters/module.html

@@ -0,0 +1,93 @@
+<div class="row">
+    <div class="col-md-6">
+        <div class="panel panel-default" ng-controller="clientFilterController">
+            <div class="panel-heading">
+                <h3 class="panel-title">
+                    Client Filtering
+                    <span class="dropdown">
+                        <ul class="dropdown-menu" aria-labelledby="clearClients">
+                            <li ng-click="clearAll()"><a>Clear all</a></li>
+                        </ul>
+                        <button class="btn btn-xs btn-default dropdown-toggle" type="button" id="clearClients"
+                                data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <span class="caret"></span>
+                        </button>
+                    </span>
+                </h3>
+            </div>
+            <div class="panel-body">
+                <div class="input-group">
+                    <span class="input-group-addon">{{ mode }} Listed MAC(s)</span>
+                    <span class="input-group-btn">
+                        <button class="btn btn-default" type="button" ng-click="toggleMode()">Switch</button>
+                    </span>
+                </div>
+                <br/>
+                <p>
+                    <textarea class="form-control" rows="15" onmouseup="getClientLineNumber(this);"
+                              ng-model="clientFilters" readonly></textarea>
+                </p>
+                <div class="input-group">
+                    <input type="text" class="form-control" placeholder="MAC Address" name="mac" ng-model="mac">
+                    <span class="input-group-btn">
+                        <button class="btn btn-default" type="button" ng-click="addClient()">Add</button>
+                        <button class="btn btn-default" type="button" ng-click="removeClient()">Remove</button>
+                    </span>
+                </div>
+                <br>
+                <div>
+                    <p style="color: #31708f"><b>Client filters</b> specify which devices, by MAC address, are either explicitly allowed to
+                        connect or
+                        explicitly denied from connecting. In Allow Mode only the listed MAC Addresses are allowed to
+                        connect. In Deny Mode, the listed MAC addresses will be prevented from connecting.</p>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="col-md-6">
+        <div class="panel panel-default" ng-controller="ssidFilterController">
+            <div class="panel-heading">
+                <h3 class="panel-title">
+                    SSID Filtering
+                    <span class="dropdown">
+                        <ul class="dropdown-menu" aria-labelledby="clearSSIDs">
+                            <li ng-click="clearAll()"><a>Clear all</a></li>
+                        </ul>
+                        <button class="btn btn-xs btn-default dropdown-toggle" type="button" id="clearSSIDs"
+                                data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <span class="caret"></span>
+                        </button>
+                    </span>
+                </h3>
+            </div>
+            <div class="panel-body">
+                <div class="input-group">
+                    <span class="input-group-addon">{{ mode }} Listed SSID(s)</span>
+                    <span class="input-group-btn">
+                                <button class="btn btn-default" type="button" ng-click="toggleMode()">Switch</button>
+                            </span>
+                </div>
+                <br/>
+                <p>
+                    <textarea class="form-control" rows="15" onmouseup="getSSIDLineNumber(this);" ng-model="ssidFilters"
+                              readonly></textarea>
+                </p>
+                <div class="input-group">
+                    <input type="text" class="form-control" placeholder="SSID" name="ssid" ng-model="ssid">
+                    <span class="input-group-btn">
+                        <button class="btn btn-default" type="button" ng-click="addSSID()">Add</button>
+                        <button class="btn btn-default" type="button" ng-click="removeSSID()">Remove</button>
+                    </span>
+                </div>
+                <br>
+                <div>
+                    <p style="color: #31708f"><b>SSID filters</b> specify the network names to which the WiFi Pineapple will respond. In Allow Mode,
+                        devices will only be allowed to associate with the WiFi Pineapple for SSID names listed. In Deny
+                        Mode, devices will be prevented from associating with the WiFi Pineapple for the listed SSID
+                        names.</p>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>

+ 12 - 0
src/pineapple/modules/Filters/module.info

@@ -0,0 +1,12 @@
+{
+    "author": "Hak5",
+    "description": "Filters",
+    "devices": [
+        "nano",
+        "tetra"
+    ],
+    "system": true,
+    "title": "Filters",
+    "version": "1.0",
+    "index": 5
+}

+ 23 - 0
src/pineapple/modules/Filters/module_icon.svg

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 256 256" style="enable-background:new 0 0 256 256;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#808080;}
+</style>
+<g>
+	<polygon class="st0" points="33.8,55.5 105,140.3 105,204.3 151,185.4 151,140.3 222.2,55.5 	"/>
+	<path class="st0" d="M128,213.4c-10.9,0-19.8,8.9-19.8,19.8c0,10.9,8.9,19.8,19.8,19.8c10.9,0,19.8-8.9,19.8-19.8
+		C147.8,222.3,138.9,213.4,128,213.4z M128,240.7c-4.1,0-7.5-3.4-7.5-7.5c0-4.1,3.4-7.5,7.5-7.5s7.5,3.4,7.5,7.5
+		C135.5,237.3,132.1,240.7,128,240.7z"/>
+	<path class="st0" d="M128,42.6c10.9,0,19.8-8.9,19.8-19.8C147.8,11.9,138.9,3,128,3c-10.9,0-19.8,8.9-19.8,19.8
+		C108.2,33.7,117.1,42.6,128,42.6z M128,15.3c4.1,0,7.5,3.4,7.5,7.5c0,4.1-3.4,7.5-7.5,7.5s-7.5-3.4-7.5-7.5
+		C120.5,18.7,123.9,15.3,128,15.3z"/>
+	<path class="st0" d="M67.9,42.6c10.9,0,19.8-8.9,19.8-19.8C87.7,11.9,78.8,3,67.9,3s-19.8,8.9-19.8,19.8
+		C48.1,33.7,57,42.6,67.9,42.6z M67.9,15.3c4.1,0,7.5,3.4,7.5,7.5c0,4.1-3.4,7.5-7.5,7.5s-7.5-3.4-7.5-7.5
+		C60.4,18.7,63.8,15.3,67.9,15.3z"/>
+	<path class="st0" d="M188.1,42.6c10.9,0,19.8-8.9,19.8-19.8C207.9,11.9,199,3,188.1,3c-10.9,0-19.8,8.9-19.8,19.8
+		C168.3,33.7,177.2,42.6,188.1,42.6z M188.1,15.3c4.1,0,7.5,3.4,7.5,7.5c0,4.1-3.4,7.5-7.5,7.5s-7.5-3.4-7.5-7.5
+		C180.6,18.7,184,15.3,188.1,15.3z"/>
+</g>
+</svg>

+ 46 - 0
src/pineapple/modules/Help/api/module.php

@@ -0,0 +1,46 @@
+<?php namespace pineapple;
+
+class Help extends SystemModule
+{
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'generateDebugFile':
+                $this->generateDebugFile();
+                break;
+
+            case 'downloadDebugFile':
+                $this->downloadDebugFile();
+                break;
+
+            case 'getConsoleOutput':
+                $this->getConsoleOutput();
+                break;
+        }
+    }
+
+    private function generateDebugFile()
+    {
+        @unlink('/tmp/debug.log');
+        $this->execBackground("(/pineapple/modules/Help/files/debug 2>&1) > /tmp/debug_generation_output");
+        $this->response = array("success" => true);
+    }
+
+    private function downloadDebugFile()
+    {
+        if (!file_exists('/tmp/debug.log')) {
+            $this->error = "The debug file is missing.";
+            return;
+        }
+        $this->response = array("success" => true, "downloadToken" => $this->downloadFile("/tmp/debug.log"));
+    }
+
+    private function getConsoleOutput()
+    {
+        $output = "";
+        if (file_exists("/tmp/debug_generation_output")) {
+            $output = file_get_contents("/tmp/debug_generation_output");
+        }
+        $this->response = array("output" => $output);
+    }
+}

+ 54 - 0
src/pineapple/modules/Help/files/debug

@@ -0,0 +1,54 @@
+#!/bin/bash
+# Simple script to gather log information
+LOG=/tmp/debug.log.tmp
+
+touch $LOG
+echo "Retrieving /proc/cpuinfo"
+echo "============CPUINFO========" > $LOG
+cat /proc/cpuinfo >> $LOG
+echo "Retrieving firmware version"
+echo -n "Firmware Version: " >> $LOG && cat /etc/pineapple/pineapple_version >> $LOG
+echo "Retrieving lsusb output"
+echo "=============LSUSB=========" >> $LOG
+lsusb >> $LOG
+echo "Retrieving disk usage information"
+echo "==============DF===========" >> $LOG
+df -h >> $LOG
+echo "Retrieving iw device list"
+echo "==============IW===========" >> $LOG
+iw dev >> $LOG
+echo "Retrieving ifconfig interface list"
+echo "===========IFCONFIG========" >> $LOG
+ifconfig -a >> $LOG
+echo "Retrieving iwconfig device list"
+echo "===========IWCONFIG========" >> $LOG
+(iwconfig 2>&1) >> $LOG
+echo "Retrieving dmesg log"
+echo "============DMESG==========" >> $LOG
+dmesg >> $LOG
+echo "Retrieving syslog"
+echo "============LOGREAD========" >> $LOG
+logread >> $LOG
+echo "Retrieving /etc/config/wireless"
+echo "===== WIRELESS CONFIG======" >> $LOG
+cat /etc/config/wireless >> $LOG
+echo "Performing site survey"
+echo "============SURVEY=========" >> $LOG
+echo -e "\tEnsuring pineapd is started"
+(/etc/init.d/pineapd start 2>&1) >> $LOG
+scan_type=0
+cat /proc/cpuinfo | grep TETRA && scan_type=2
+echo -e "\tRunning scan type $scan_type for 15 seconds"
+(/usr/bin/pineap /tmp/pineap.conf run_scan 15 $scan_type 2>&1) >> $LOG
+sleep 2
+scan_id="$(/usr/bin/pineap /tmp/pineap.conf get_status | grep scanID | awk '{print $2}' | sed 's/,//')"
+echo -e "\tNew scan id: $scan_id"
+echo -e "\tWaiting for scan to finish"
+while [ "$(/usr/bin/pineap /tmp/pineap.conf get_status | grep 'scanRunning' | awk '{print $2}' | sed 's/,//')" == "true" ]; do sleep 1; done
+echo -e "\tRetrieving scan results, appending to debug log"
+chmod a+x /pineapple/modules/Help/files/dumpscan.php
+/pineapple/modules/Help/files/dumpscan.php $scan_id >> $LOG
+echo "Renaming debug file"
+mv $LOG /tmp/debug.log
+echo "Completed Debug Filed Generation"
+logger "Completed Debug File Generation"

+ 135 - 0
src/pineapple/modules/Help/files/dumpscan.php

@@ -0,0 +1,135 @@
+#!/usr/bin/php-cgi -q
+<?php namespace pineapple;
+
+include_once('/pineapple/api/DatabaseConnection.php');
+
+abstract class EncryptionFields
+{
+    const WPA = 0x01;
+    const WPA2 = 0x02;
+    const WEP = 0x04;
+    const WPA_PAIRWISE_WEP40 = 0x08;
+    const WPA_PAIRWISE_WEP104 = 0x10;
+    const WPA_PAIRWISE_TKIP = 0x20;
+    const WPA_PAIRWISE_CCMP = 0x40;
+    const WPA2_PAIRWISE_WEP40 = 0x80;
+    const WPA2_PAIRWISE_WEP104 = 0x100;
+    const WPA2_PAIRWISE_TKIP = 0x200;
+    const WPA2_PAIRWISE_CCMP = 0x400;
+    const WPA_AKM_PSK = 0x800;
+    const WPA_AKM_ENTERPRISE = 0x1000;
+    const WPA_AKM_ENTERPRISE_FT = 0x2000;
+    const WPA2_AKM_PSK = 0x4000;
+    const WPA2_AKM_ENTERPRISE = 0x8000;
+    const WPA2_AKM_ENTERPRISE_FT = 0x10000;
+    const WPA_GROUP_WEP40 = 0x20000;
+    const WPA_GROUP_WEP104 = 0x40000;
+    const WPA_GROUP_TKIP = 0x80000;
+    const WPA_GROUP_CCMP = 0x100000;
+    const WPA2_GROUP_WEP40 = 0x200000;
+    const WPA2_GROUP_WEP104 = 0x400000;
+    const WPA2_GROUP_TKIP = 0x800000;
+    const WPA2_GROUP_CCMP = 0x1000000;
+}
+
+function printEncryption($encryptionType)
+{
+    $retStr = '';
+    if ($encryptionType === 0) {
+        return 'Open';
+    }
+    if ($encryptionType & EncryptionFields::WEP) {
+        return 'WEP';
+    } else if (($encryptionType & EncryptionFields::WPA) && ($encryptionType & EncryptionFields::WPA2)) {
+        $retStr .= 'WPA Mixed ';
+    } else if ($encryptionType & EncryptionFields::WPA) {
+        $retStr .= 'WPA ';
+    } else if ($encryptionType & EncryptionFields::WPA2) {
+        $retStr .= 'WPA2 ';
+    }
+    if (($encryptionType & EncryptionFields::WPA2_AKM_PSK) || ($encryptionType & EncryptionFields::WPA_AKM_PSK)) {
+        $retStr .= 'PSK ';
+    } else if (($encryptionType & EncryptionFields::WPA2_AKM_ENTERPRISE) || ($encryptionType & EncryptionFields::WPA_AKM_ENTERPRISE)) {
+        $retStr .= 'Enterprise ';
+    } else if (($encryptionType & EncryptionFields::WPA2_AKM_ENTERPRISE_FT) || ($encryptionType & EncryptionFields::WPA_AKM_ENTERPRISE_FT)) {
+        $retStr .= 'Enterprise FT ';
+    }
+    $retStr .= '(';
+    if (($encryptionType & EncryptionFields::WPA2_PAIRWISE_CCMP) || ($encryptionType & EncryptionFields::WPA_PAIRWISE_CCMP)) {
+        $retStr .= 'CCMP ';
+    }
+    if (($encryptionType & EncryptionFields::WPA2_PAIRWISE_TKIP) || ($encryptionType & EncryptionFields::WPA_PAIRWISE_TKIP)) {
+        $retStr .= 'TKIP ';
+    }
+    if (($encryptionType & EncryptionFields::WPA2_PAIRWISE_WEP40) || ($encryptionType & EncryptionFields::WPA_PAIRWISE_WEP40)) {
+        $retStr .= 'WEP40 ';
+    }
+    if (($encryptionType & EncryptionFields::WPA2_PAIRWISE_WEP104) || ($encryptionType & EncryptionFields::WPA_PAIRWISE_WEP104)) {
+        $retStr .= 'WEP104 ';
+    }
+    $retStr = substr($retStr, 0, -1);
+    $retStr .= ')';
+    return $retStr;
+}
+
+if (count($argv) < 2) {
+	exit("Usage: ${argv[0]} [scan id]\n");
+}
+
+$scanID = intval($argv[1]);
+$scanDBPath = exec("uci get pineap.@config[0].recon_db_path");
+if (!file_exists($scanDBPath)) {
+	exit("File ${scanDBPath} does not exist\n");
+}
+
+$dbConnection = new DatabaseConnection($scanDBPath);
+if ($dbConnection === NULL) {
+	exit("Unable to create database connection\n");
+}
+
+if (isset($dbConnection->error['databaseConnectionError'])) {
+	exit($dbConnection->strError() . "\n");
+}
+
+$data = array();
+$data[$scanID] = array();
+$aps = $dbConnection->query("SELECT scan_id, ssid, bssid, encryption, hidden, channel, signal, wps, last_seen FROM aps WHERE scan_id='%d';", $scanID);
+foreach ($aps as $ap_row) {
+    $data[$scanID]['aps'][$ap_row['bssid']] = array();
+    $data[$scanID]['aps'][$ap_row['bssid']]['ssid'] = $ap_row['ssid'];
+    $data[$scanID]['aps'][$ap_row['bssid']]['encryption'] = printEncryption($ap_row['encryption']);
+    $data[$scanID]['aps'][$ap_row['bssid']]['hidden'] = $ap_row['hidden'];
+    $data[$scanID]['aps'][$ap_row['bssid']]['channel'] = $ap_row['channel'];
+    $data[$scanID]['aps'][$ap_row['bssid']]['signal'] = $ap_row['signal'];
+    $data[$scanID]['aps'][$ap_row['bssid']]['wps'] = $ap_row['wps'];
+    $data[$scanID]['aps'][$ap_row['bssid']]['last_seen'] = $ap_row['last_seen'];
+    $data[$scanID]['aps'][$ap_row['bssid']]['clients'] = array();
+    $clients = $dbConnection->query("SELECT scan_id, mac, bssid, last_seen FROM clients WHERE scan_id='%d' AND bssid='%s';", $ap_row['scan_id'], $ap_row['bssid']);
+    foreach ($clients as $client_row) {
+        $data[$scanID]['aps'][$ap_row['bssid']]['clients'][$client_row['mac']] = array();
+        $data[$scanID]['aps'][$ap_row['bssid']]['clients'][$client_row['mac']]['bssid'] = $client_row['bssid'];
+        $data[$scanID]['aps'][$ap_row['bssid']]['clients'][$client_row['mac']]['last_seen'] = $client_row['last_seen'];
+    }
+}
+
+$data[$scanID]['outOfRangeClients'] = array();
+$clients = $dbConnection->query("
+    SELECT t1.mac, t1.bssid, t1.last_seen FROM clients t1
+    LEFT JOIN aps t2 ON
+    t2.bssid = t1.bssid WHERE t2.bssid IS NULL AND
+    t1.bssid != 'FF:FF:FF:FF:FF:FF' COLLATE NOCASE AND t1.scan_id='%d';
+    ", $client_row['scan_id']);
+
+foreach ($clients as $client_row) {
+    $data[$scanID]['outOfRangeClients'][$client_row['mac']] = array();
+    $data[$scanID]['outOfRangeClients'][$client_row['mac']] = $client_row['bssid'];
+}
+
+$data[$scanID]['unassociatedClients'] = array();
+$clients = $dbConnection->query("SELECT mac FROM clients WHERE bssid='FF:FF:FF:FF:FF:FF' COLLATE NOCASE;");
+
+foreach ($clients as $client_row) {
+    array_push($data[$scanID]['unassociatedClients'], $client_row['mac']);
+}
+
+file_put_contents("php://stdout", json_encode($data, JSON_PRETTY_PRINT));

+ 61 - 0
src/pineapple/modules/Help/js/module.js

@@ -0,0 +1,61 @@
+registerController("DebugController", ['$api', '$scope', '$timeout', '$interval', function($api, $scope, $timeout, $interval){
+    $scope.loading = false;
+    $scope.debugStarted = false;
+    $scope.output = "";
+    $scope.getOutputInterval = null;
+
+    $scope.generateDebugFile = (function(){
+        $api.request({
+            module: "Help",
+            action: "generateDebugFile"
+        }, function(response) {
+            if (response.success === true) {
+                $scope.loading = true;
+                $scope.debugStarted = true;
+                $timeout($scope.downloadDebugFile, 15000);
+                if ($scope.getOutputInterval === null) {
+                    $scope.getOutputInterval = $interval($scope.getOutput, 700);
+                }
+            }
+        })
+    });
+
+    $scope.getOutput = (function() {
+        $api.request({
+            module: "Help",
+            action: "getConsoleOutput"
+        }, function(response) {
+            $scope.output = response.output;
+            var el = $("#output");
+            if (el.length) {
+                el.scrollTop(el[0].scrollHeight - el.height())
+            }
+        });
+    });
+
+    $scope.tryAgainSoon = (function(){
+        $timeout($scope.downloadDebugFile, 2000);
+    });
+
+    $scope.downloadDebugFile = (function(){
+        $api.request({
+            module: "Help",
+            action: "downloadDebugFile"
+        }, function(response) {
+            if (response.success === true) {
+                $scope.loading = false;
+                window.location = '/api/?download=' + response.downloadToken;
+                $interval.cancel($scope.getOutputInterval);
+            } else {
+                $scope.tryAgainSoon();
+            }
+        })
+    });
+}]);
+registerController("HelpController", ['$api', '$scope', function($api, $scope) {
+    $scope.device = "";
+
+    $api.onDeviceIdentified(function(device, scope) {
+        scope.device = device;
+    }, $scope);
+}]);

+ 418 - 0
src/pineapple/modules/Help/module.html

@@ -0,0 +1,418 @@
+<div class="panel-group" id="accordion">
+    <div class="panel panel-default">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#FeedbackHelp" data-parent="#accordion">
+            <h4 class="panel-title">Feedback and Support</h4>
+        </div>
+        <div id="FeedbackHelp" class="panel-collapse collapse in" ng-controller="DebugController">
+            <div class="panel-body">
+                <p>
+                   The WiFi Pineapple is more than hardware or software -- it's home to a helpful community of creative penetration testers and IT professionals. Welcome!
+                </p>
+
+                <p>
+                    The <a href="https://www.wifipineapple.com/forum">forums</a> are a great place to share feedback and ideas. You'll also find community support and discussion as well as modules, tutorials and firmware releases. Be sure to use the search feature to find answers to common questions.
+                </p>
+
+                <p>
+                    Find a bug? If it hasn't already been reported, you're encouraged to report it along with detailed steps to reproduce the issue at the <a href="https://www.wifipineapple.com/bugs">bug tracker</a>.
+                </p>
+
+                <p>
+                    Looking for something a little more informal? The IRC channel is home to a passionate group of WiFi Pineapple enthusiasts. Join us at #pineapple on irc.hak5.org -- though be aware that views expressed are not those of Hak5 or the WiFi Pineapple team.
+                </p>
+
+                <p>
+                    <a href="https://hak5.org">© Hak5 LLC</a>.
+                </p>
+
+                <p>
+                    Tips for reporting issues:
+                    <ul>
+                        <li>Search the bug tracker and forums to see if it has already been reported. If the issue is unresolved, sharing your experience may aid in developing a remedy.</li>
+                        <li>Share your configuration. Host OS, web browser and version, WiFi Pineapple firmware version as well as your particular network setup are all important factors to share. Be as descriptive as possible.</li>
+                        <li>Describe how to reproduce the problem step by step. This is immensely important in diagnosing any problem.</li>
+                        <li>Provide logs when possible. Usually the output of commands such as "dmesg", "lsusb", "iwconfig", "ifconfig -a", "cat /etc/config/wireless" are very helpful in diagnosing issues.</li>
+                        <li>Be courteous</li>
+                    </ul>
+                </p>
+                <p class="text-center">
+                    <button class="btn btn-default btn-xs" ng-click="generateDebugFile()" ng-hide="loading">Download Debug File</button>
+                    <div class="text-center" ng-show="loading">
+                        <img src="img/throbber.gif">
+                        <div>The debug file is being generated. Once complete the download will start.</div>
+                    </div>
+                    <h5 class="panel-title" ng-show="debugStarted">Progress</h5>
+                    <p>
+                        <textarea id="output" ng-show="debugStarted" id="debugGenerationOutput" class="form-control autoselect" rows="15" ng-model="output" readonly></textarea>
+                    </p>
+                </p>
+            </div>
+        </div>
+    </div>
+
+    <div class="panel panel-default">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#DashboardHelp" data-parent="#accordion">
+            <h4 class="panel-title">Dashboard</h4>
+        </div>
+        <div id="DashboardHelp" class="panel-collapse collapse">
+            <div class="panel-body">
+                <p>
+                    The dashboard provides an at-a-glance view of the WiFi Pineapple status, landing page browser stats, notifications and bulletins.
+                </p>
+                <p>
+                    <b>Landing Page Browser Stats</b> will display hits from popular web browsers when the Landing Page is enabled from Configuration. <b>Notifications</b> will display notifications from modules. The <b>Bulletins</b> feature fetches the latest project information from wifipineapple.com.
+                </p>
+            </div>
+        </div>
+    </div>
+
+    <div class="panel panel-default">
+        <div class="panel-heading pointer collapsed" data-toggle="collapse" data-target="#ReconHelp" data-parent="#accordion">
+            <h4 class="panel-title">Recon</h4>
+        </div>
+        <div id="ReconHelp" class="panel-collapse collapse">
+            <div class="panel-body">
+                <p>
+                    Unlike traditional War Driving, whereby the auditor passively listens for beacons being advertised by Access Points to paint a picture of the surrounding WiFi landscape, the WiFi Pineapple Recon goes one giant step further.
+                </p>
+                <p>
+                    By monitoring channels for both beacons and data activity, Recon paints a more complete picture by combining Access Points with their respective clients. With the WiFi landscape displayed in this manner, a tester can quickly identify potential targets from Recon and immediately take action.
+                </p>
+                <p>
+                    Recon allows the auditor to scan for nearby Access Points and Clients. Clients are identified by sniffing for active traffic and are displayed 
+underneath their 
+parent Access Point. If a Client is associated to an Access Point but idle, it may not appear in the list. Increasing scan duration from the drop-down allows the sniffer to see more potential traffic on each channel. Clients or Access Points that appear in blue have MAC addresses that are locally assigned to the corresponding device. This could be an indication of MAC randomization or spoofing, but also appears when a vendor assigns MACs that are not registered with the IEEE Registration Authority. Italicized MACs have saved information from the Notes module.
+                </p>
+                <p>
+                    The SSID, MAC, Security, Channel and Signal of Access Points are displayed in the table view. Clients are listed as MAC addresses only.
+                </p>
+                <p>
+                    Clicking the menu button next to an MAC address shows a menu providing buttons to add or remove the MAC from the PineAP Filter or PineAP Tracking feature. Opening this menu will automatically look up the MAC address in the OUI database if it has been downloaded. In order to download the OUI database, either the WiFi Pineapple or the web browser itself must have internet access. Deauth uses the multiplier to send multiple deauthentication frames to the target Client. A multiplier of 2 is twice as many deauthentication frames as a multiplier of 1. The menu button is disabled when a scan is active (ie. not stopped or paused).
+                </p>
+                <p>
+                    Clicking the menu button next to an SSID shows a menu providing buttons to add or remove the SSID from the PineAP Pool or PineAP Filter. Deauth Clients will send deauthentication frames to all associated clients currently recognized by Recon using the multiplier. A multiplier of 2 is twice as many deauthentication frames as a multiplier of 1.
+                </p>
+                <p>
+                    Clicking the clone feature for enterprise access points copies the data to the PineAP Enterprise feature so that the access point can be impersonated to capture clients and credentials.
+                </p>
+                <p>
+                    Unassociated Clients show in a unique table listed by MAC Address. These Clients have active radios, however are not associated to an Access Point.
+                </p>
+                <p>
+                    Out Of Range Clients will display in a unique table along with their relationship to their parent Access Point by MAC address only.
+                </p>
+                <p>
+                    When the scan duration is set to Continuous, the scan will run indefinitely until it is stopped by the user. Checking the Live box enables Recon++ and live result streaming. A websocket pushes new scan data to the web interface every five seconds, providing a real time view of the wireless landscape.
+                </p>
+            </div>
+        </div>
+    </div>
+
+    <div class="panel panel-default">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#ClientHelp" data-parent="#accordion">
+            <h4 class="panel-title">Clients</h4>
+        </div>
+        <div id="ClientHelp" class="panel-collapse collapse">
+            <div class="panel-body">
+                <p>
+                    The WiFi Pineapple will allow clients to connect if Allow Associations is checked in PineAP. Connected clients will list in the Clients view along with their respective MAC Address, IP Address, the SSID to which they have connected (if Log Probes is enabled in PineAP) and Hostname. If the SSID or Hostname is unavailable it will display as such.
+                </p>
+                <p>
+                    The Kick button allows the auditor to remove a client from the WiFi Pineapple network.
+                </p>
+                <p>
+                    Clicking the menu button next to an MAC address shows a menu providing buttons to add or remove the MAC from the PineAP Filter or PineAP Tracking feature.
+                </p>
+                <p>
+                    Clicking the menu button next to an SSID shows a menu providing buttons to add or remove the SSID from the PineAP Pool or PineAP Filter.
+                </p>
+                <p>
+                    The Clients table can be updated by clicking the Refresh button.
+                </p>
+            </div>
+        </div>
+    </div>
+
+    <div class="panel panel-default">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#FiltersHelp" data-parent="#accordion">
+            <h4 class="panel-title">Filters</h4>
+        </div>
+        <div id="FiltersHelp" class="panel-collapse collapse">
+            <div class="panel-body">
+                <p>
+                    Filtering may be performed by Client MAC Address or SSID. Both Deny and Allow modes are supported and this option may be toggled using the switch button.
+                </p>
+                <p>
+                    <b>Client Filtering</b><br/>
+                    In Deny Mode, Clients with MAC Addresses listed in the Client Filter will not be able to connect to the WiFi Pineapple. In Allow Mode, only Clients with MAC Addresses listed in the Client Filter will be able to connect. When performing an audit, it is best to use Allow Mode to ensure that only clients within the scope of engagement are targeted.
+                </p>
+                <p>
+                    Client MAC Addresses and SSIDs may be added from menu buttons associated with their respective listings in Recon or Client views. 
+                </p>
+                <p>
+                    <b>SSID Filtering</b><br/>
+                    In Deny Mode, clients will not be able to associate with the WiFi Pineapple if they are attempting to connect to an SSID listed in the filter. In Allow Mode, clients will only be able to associate with the WiFi Pineapple if the SSID they are attempting to connect to is listed in the filter.
+                </p>
+                <p>
+                    SSIDs may be added to the filter from the menu buttons associated with their respective listings in Recon.
+                </p>
+                <p>
+                    <b>Managing Filters</b><br/>
+                    Filtered Clients and SSIDs will display in the lists. Client MAC addresses and SSIDs may be added to the list manually by using the text input field and Add button. Clicking a Client MAC or SSID will populate the text input field and clicking Remove will remove the entry from the Filter list.
+                </p>
+            </div>
+        </div>
+    </div>
+
+    <div class="panel panel-default">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#PineAPHelp" data-parent="#accordion">
+            <h4 class="panel-title">PineAP</h4>
+        </div>
+        <div id="PineAPHelp" class="panel-collapse collapse">
+            <div class="panel-body">
+                <p>
+                    PineAP is an effective, modular rogue access point suite designed to aid the WiFi auditor in collecting clients by thoroughly mimicking Preferred Networks including Enterprise access points. When acting as an Enterprise access point, it can capture clients and credentials.
+                </p>
+                <p>
+                    <b>Allow Associations</b> - when enabled, Client devices will be allowed to associate with the WiFi Pineapple through any requested SSID. E.g. If a Client device sends a Probe Request for SSID "example" the WiFi Pineapple will acknowledge the request, respond and allow the Client device to associate and connect to the WiFi Pineapple network. This feature works in conjunction with Client and SSID filtering. Clients attempting to associate to an SSID disallowed by Filters will be unable to associate. Clients with a MAC blocked by Filters will not be able to associate. When disabled, clients will not be allowed to associate. Formerly named Karma.
+                </p>
+                <p>
+                    <b>PineAP Daemon</b> - This daemon must be enabled in order to use the Log PineAP Events, Client Connect Notifications, Client Disconnect Notifications, Beacon Response, Capture SSIDs to Pool and Broadcast SSID pool features. The PineAP Daemon will coordinate the appropriate actions based on Source and Target MAC settings as well as the Beacon Response and SSID Broadcast intervals. This feature requires access to wlan1 and cannot be used in conjunction with WiFi Client Mode if wlan1 is used. PineAP Daemon must be enabled and PineAP Settings must be saved before the associated features will be available.
+                </p>
+                <p>
+                    <b>Autostart PineAP</b>- when enabled, the PineAP daemon will start automatically at boot.
+                </p>
+                <p>
+                    <b>Log PineAP Events</b> - when enabled, Client device Probe Requests, Client Associations to, and dissassociations from the WiFi Pineapple will be logged. This feature provides information for analysis from the Logging view. If disabled, Associations will not be logged and may not appear in the SSID column from the Clients view.
+                </p>
+                <p>
+                    <b>Client Connect Notifications</b>- when enabled, the WiFi auditor will receive notifications when clients connect to the pineapple.
+                </p>
+                <p>
+                    <b>Client Disconnect Notifications</b>>- when enabled, the WiFi auditor will receive notifications when clients disconnect from the pineapple.
+                </p>
+                <p>
+                    <b>Capture SSIDs to Pool</b> - when enabled, the sniffer will save the SSID data of captured Probe Requests to the SSID Pool. This passive feature benefits the Broadcast SSID Pool feature. The SSID Pool may also be managed manually.
+                </p>
+                <p>
+                    <b>Beacon Response</b> - when enabled, targeted beacons will be transmitted to Client devices in response to a Probe Request with the appropriate SSID. These beacons will not be transmitted to broadcast, but rather specifically to the device making the probe request. This prevents the beacon from being visible to other devices. If Allow Associations is enabled and the Client device associates with the WiFi Pineapple, then targeted Beacon Responses will continue to transmit to the Client device for a period of time. Beacon Responses will use the Source MAC setting, which is also shared with the Broadcast SSID Pool feature.The Beacon Response Interval will dictate how quickly they are transmitted.
+                </p>
+                <p>
+                    <b>Broadcast SSID Pool</b> - when enabled, the SSID Pool will be broadcasted as beacons using the Source MAC and Target MAC settings at the interval specified. Formerly named Dogma.
+                </p>
+                <p>
+                    <b>Broadcast SSID Pool Interval</b> - Specifies the Interval in which to Broadcast SSIDs from the Pool. Aggressive requires more CPU usage while Lower requires less.
+                </p>
+                <p>
+                    <b>Beacon Response Interval</b> - Specifies the Interval in which to transmit Beacon Responses. Aggressive requires more CPU usage while Lower requires less.
+                </p>
+                <p>
+                    <b>Source MAC</b> - by default, this is the MAC address of wlan0 on the WiFi Pineapple. This is the interface for which associations may be allowed and also hosts the Management Access Point. The MAC address of wlan0 may be changed from the Networking view. This MAC address may be set to that of a secondary WiFi Pineapple if desired.
+                </p>
+                <p>
+                    <b>Target MAC</b> - by default, this is the broadcast MAC address FF:FF:FF:FF:FF:FF. Frames transmitted to broadcast will be seen by all nearby Client devices. Setting the Client MAC address will target PineAP features at the single device. Similar to Beacon Response, only SSIDs Broadcast from the Pool will be visible to the targeted Client device. When used in conjunction with Filtering, this feature enables precision device targeting.
+                </p>
+                <p>
+                    <b>SSID Pool</b> - populated automatically when the Capture SSID Pool feature is enabled. May also be added to manually using the text field and Add button. Similarly, clicking a listed SSID will populate the text field allowing for the removal of the entry using the Remove button. From the SSID Pool Menu, Download SSID Pool downloads the list of SSIDs in the database and Clear SSID Pool will remove all entries.
+                </p>
+                    <b>PineAP Enterprise</b>
+                    <ul>
+                        <li>
+                            <b>Dropdown</b>-
+                            <ul>
+                                <li>
+                                    <b>Clear Certificates</b>- Clears the generated SSL certificates for use with the enterprise network.
+                                </li>
+                                <li>
+                                    <b>Download Credentials (JTR Format)</b>- Downloads all captured MSCHAPv2 credentials in John the Ripper format for easy cracking.
+                                </li>
+                                <li>
+                                    <b>Download Credentials (Hashcat Format)</b>- Downloads all captured MSCHAPv2 credentials in John the Ripper format for easy cracking.
+                                </li>
+                            </ul> 
+                        <li>
+                            <b>Enable</b> - Enables enterprise credential capture.
+                        </li>
+                        <li>
+                            <b>Enable Passthrough</b>- Enables authentication passthrough on the enterprise network.
+                        </li>
+                        <li>
+                            <b>Enterprise SSID</b>- The SSID of the enterprise network.
+                        </li>
+                        <li>
+                            <b>Enterprise MAC</b>- The BSSID of the enterprise network.
+                        </li>
+                        <li>
+                            <b>Encryption Type</b>- The type of encryption employed by the enterprise network. This may be set to any combination of the WPA and WPA2 versions and CCMP and TKIP cipher suites.
+                        </li>
+                        <li>
+                            <b>Downgrade Attack</b>- Specifies whether or not a downgrade attack should be enabled and whether to downgrade to to GTC (which is tunneled plaintext auth) or MSCHAPv2 (which uses a weak hashing algorithm).
+                        </li>
+                    </ul>
+                <p>
+                </p>
+            </div>
+        </div>
+    </div>
+
+    <div class="panel panel-default">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#TrackingHelp" data-parent="#accordion">
+            <h4 class="panel-title">Tracking</h4>
+        </div>
+        <div id="TrackingHelp" class="panel-collapse collapse">
+            <div class="panel-body">
+                <p>
+                    The tracking feature will continuously scan for specified Clients by MAC address and execute a customizable Tracking Script. This feature requires the Log Probes and/or Log Associations features of PineAP to be enabled. 
+                </p>
+                <p>
+                    Clients may be specified manually using the text field and add button. Clients may also be added to the Client Tracking List by using the PineAP Tracking Add MAC button from an associated MAC address within the Clients view or Recon view. Selecting a MAC address from the Client Tracking List will populate the text field for removal using the Remove button. 
+                </p>
+                <p>
+                    When a client is identified by a logged Probe or Association, the customizable Tracking Script will execute. The Tracking Script defines variables for the Client MAC address, the identification type (Probe or Association) and the SSID with which the Client is Probing or Associating.
+                </p>
+            </div>
+        </div>
+    </div>
+
+    <div class="panel panel-default">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#LoggingHelp" data-parent="#accordion">
+            <h4 class="panel-title">Logging</h4>
+        </div>
+        <div id="LoggingHelp" class="panel-collapse collapse">
+            <div class="panel-body">
+                <p>
+                    The Logging view displays the PineAP Log, System Log, Dmesg and Reporting Log.
+                </p>
+                <p>
+                    <b>PineAP Log</b> - chronologically displays PineAP events if Log Probes and/or Log Associations are enabled. Each event contains a timestamp, event type (Probe Request or Association), the MAC address of the Client device, and the SSID for which the device is Probing or Associating.
+                </p>
+                <p>
+                    <b>PineAP Log Filtering</b><br/>
+                    The Display Probes and Display Associations checkboxes enable the auditor to toggle the display of Probes or Associations. The Remove Duplicates checkbox will remove any duplicate entry, regardless of timestamp. For example, if a Client transmits a Probe Request for SSID "example" 10 times in 1 hour, checking the Remove Duplicates box will show only the first entry.
+                </p>
+                <p>
+                    Filtering by MAC address and SSID is supported by completing the associated text fields. For example, if de:ad:be:ef:c0:fe is input in the MAC text field, only that Client device activity will show in the PineAP Log. MAC and SSID filters also support Regular Expressions. Similarly the Log may be filtered by SSID.
+                </p>
+                <p>
+                    Filters do not apply until the Apply Filter button is pressed. Clear Filter will reset to the default and display all captured data. Refresh Log will obtain the latest log data from PineAP and Clear Log will empty the Log File. By default PineAP log database is located in /tmp and will not be saved after a reboot.
+                </p>
+            </div>
+        </div>
+    </div>
+
+    <div class="panel panel-default">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#ReportingHelp" data-parent="#accordion">
+            <h4 class="panel-title">Reporting</h4>
+        </div>
+        <div id="ReportingHelp" class="panel-collapse collapse" ng-controller="HelpController">
+            <div class="panel-body">
+                <p>
+                    This feature enables the auditor to generate reports at a specified interval. The report may be sent via email and/or saved locally<span ng-if="device == 'nano'"> on a suitable SD card. See the Format SD Card option from the USB menu on the Advanced view to setup a new card</span>. Email Configuration must be complete in order for the Send Report via email function to operate successfully.
+                </p>
+                <p>
+                    The Report Contents may contain: the PineAP Log with an option to clear after generating the report, a PineAP Site Survey similar to the Recon View with option to specify AP & Client scan duration, and PineAP Probing and Tracked Clients.
+                </p>
+            </div>
+        </div>
+    </div>
+
+    <div class="panel panel-default">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#NetworkingHelp" data-parent="#accordion">
+            <h4 class="panel-title">Networking</h4>
+        </div>
+        <div id="NetworkingHelp" class="panel-collapse collapse">
+            <div class="panel-body">
+                <p>
+                    From the Networking view, the auditor may make changes to the Routing, Access Point, MAC Addresses, Hostname and connect to an Access Point using WiFi Client Mode. The view also contains an OUI lookup tool.
+                </p>
+                <p>
+                    <b>Route</b> - the Kernel IP routing table is displayed and may be modified for the selected interface. The Route menu enables the auditor to Restart DNS. By default the expected Default Gateway is 172.16.42.42. When using the WiFi Pineapple Connector Android app, IP routing will automatically update to use usb0 as the default gateway.
+                </p>
+                <p>
+                    <b>Access Point</b> - The WiFi Pineapple primary open access point and management access point may be configured. Both the open and management access point share the same channel. The open access point may be hidden and the management access point may be disabled.
+                </p>
+                <p>
+                    <b>OUI Lookup</b> - this feature allows the auditor to look up MAC addresses and prefixes as needed. The dropdown menu adjacent to OUI Lookup provides the option to delete and reset the OUI database, which is a mirror of the copy hosted by Wireshark and can is downloaded from <a href="https://wifipineapple.com/oui.txt">wifipineapple.com</a>.
+                </p>
+                <p>
+                    <b>WiFi Client Mode</b> - this feature enables the auditor to connect the WiFi Pineapple to another wireless access point for Internet or local network access. When using WiFi Client Mode, the IP routing will automatically update to use the selected interface. The WiFi Pineapple can be used with a number of supported USB WiFi adapters to add a third (wlan2) interface. wlan0 is reserved for use by the Access Point and wlan1 is required by PineAP and cannot be used if the PineAP Daemon and its subsequent features are being used. 
+                </p>
+                <p>
+                    To connect to a nearby Access Point, select the desired Interface and click Scan. From the Access Point list, choose the desired network, enter the Passphrase (if required) and click Connect. Once connected the WiFi Pineapple IP address will display and the Default Route will update to that of the newly connected network. Click Disconnect to end the connection.
+                </p>
+                <p>
+                    <b>MAC Address</b> - The Current MAC address for the selected interface will display. A New MAC address may be specified manually, or set randomly using the New MAC text field and Set New MAC or Set Random MAC buttons. MAC Addresses may be reset to default from the MAC Address menu button. Changing MAC addresses may disconnect connected clients from the WiFi Pineapple.
+                </p>
+                <p>
+                    <b>Advanced</b> - The Hostname may be updated using the hostname text field and Update Hostname button. Wireless configuration may be reset using the Reset WiFi Config to Defaults option from the Advanced menu button. The output of ifconfig is displayed.
+                </p>
+            </div>
+        </div>
+    </div>
+
+    <div class="panel panel-default">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#ConfigurationHelp" data-parent="#accordion">
+            <h4 class="panel-title">Configuration</h4>
+        </div>
+        <div id="ConfigurationHelp" class="panel-collapse collapse">
+            <div class="panel-body">
+                <p>
+                    The Configuration view provides the auditor with means to set general settings and modify the landing page.
+                </p>
+                <p>
+                    <b>General</b> - Timezone settings is displayed and may be manually selected. The system password may be set. The WiFi Pineapple may be rebooted or reset to factory defaults from the General menu button.
+                </p>
+                <p>
+                    <b>Landing Page</b> - when enabled, this feature will act as a captive portal. New clients connecting to the WiFi Pineapple will be forwarded to this landing page. Some client devices will automatically launch a browser to this page upon connection. Landing page browser stats will display on the dashboard. PHP and HTML are accepted. The Landing Page may only display if the WiFi Pineapple has an Internet connection.
+                </p>
+                <p>
+                    <b>Button Script</b> - this feature allows the auditor to control the action performed when the reset button is pressed. The default is a shell script that reboots the pineapple, but this can be modified to better suit the auditor's needs.
+                </p>
+            </div>
+        </div>
+    </div>
+
+    <div class="panel panel-default">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#AdvancedHelp" data-parent="#accordion">
+            <h4 class="panel-title">Advanced</h4>
+        </div>
+        <div id="AdvancedHelp" class="panel-collapse collapse">
+            <div class="panel-body">
+                <p>
+                    The Advanced view provides the auditor with information on system resources, USB devices, file system table, CSS and the ability to upgrade the WiFi Pineapple firmware.
+                </p>
+                <p>
+                    <b>Resources</b> - displays file system disk usage and memory. From the Resources menu button Page Caches may be dropped.
+                </p>
+                <p>
+                    <b>USB</b> - displays connected USB peripherals and allows the auditor to set the file system table (fstab). SD cards may be formatted from the USB menu button.
+                </p>
+                <p>
+                    <b>CSS</b> - The WiFi Pineapple Web Interface stylesheet may be modified.
+                </p>
+                <p>
+                    <b>Manage API Tokens</b> - displays existing API tokens that can be used to authenticate remotely with the pineapple's API without a password. Tokens can be revoked and copied from this view. Note that when an API token is clicked in this view, the entire token is selected despite it appearing truncated with ellipsis.
+                </p>
+                <p>
+                    <b>Generate New Token</b> - allows the auditor to securely generate new API tokens.
+                </p>
+                <p>
+                    <b>Firmware Upgrade</b> - displays current firmware version and allows the auditor to check for updates. This requires an Internet connection and will initiate a connection to WiFiPineapple.com. If an update is available, the changelog will display and the option to Perform Upgrade will be available. Users are advised to carefully read the warnings related to the firmware upgrade feature.
+                </p>
+            </div>
+        </div>
+    </div>
+
+    <div class="panel panel-default">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#NotesHelp" data-parent="#accordion">
+            <h4 class="panel-title">Notes</h4>
+        </div>
+        <div id="NotesHelp" class="panel-collapse collapse">
+            <div class="panel-body">
+                <p>
+                    This feature enables the auditor to easily take notes on different clients and APs. MAC addresses can also be given nicknames, making it easy for the auditor to keep track of important devices. The Download button generates a JSON file containing all the notes taken. Refresh refreshes the view to check for new notes. The delete button to the right of each note allows the note and nickname associated with a device to be easily removed.
+                </p>
+            </div>
+        </div>
+    </div>
+</div>

+ 12 - 0
src/pineapple/modules/Help/module.info

@@ -0,0 +1,12 @@
+{
+    "author": "Hak5",
+    "description": "Information and debug log generation for the WiFi Pineapple.",
+    "devices": [
+        "nano",
+        "tetra"
+    ],
+    "system": true,
+    "title": "Help",
+    "version": "1.1",
+    "index": 14
+}

+ 12 - 0
src/pineapple/modules/Help/module_icon.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 256 256" style="enable-background:new 0 0 256 256;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#808080;}
+</style>
+<path class="st0" d="M128,22C69.5,22,22,69.5,22,128c0,58.5,47.5,106,106,106c58.5,0,106-47.5,106-106C234,69.5,186.5,22,128,22z
+	 M126.1,202.5c-11.1,0-20-9-20-20c0-11.1,9-20,20-20c11.1,0,20,9,20,20C146.2,193.5,137.2,202.5,126.1,202.5z M143.8,143.4v11.2
+	h-34.4v-16.3c0-18.7,29.4-29.3,29.4-40.4c0-7.7-7.2-12.6-13.7-12.6c-8,0-14.5,6.9-20.8,15.1l-21.6-20c12.9-16.5,27.9-26.7,46.4-26.7
+	c24.6,0,44,17.3,44,44.7C173.2,124.7,143.8,131.2,143.8,143.4z"/>
+</svg>

+ 123 - 0
src/pineapple/modules/Logging/api/module.php

@@ -0,0 +1,123 @@
+<?php namespace pineapple;
+
+class Logging extends SystemModule
+{
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'getSyslog':
+                $this->getSyslog();
+                break;
+
+            case 'getDmesg':
+                $this->getDmesg();
+                break;
+
+            case 'getReportingLog':
+                $this->getReportingLog();
+                break;
+
+            case 'getPineapLog':
+                $this->getPineapLog();
+                break;
+
+            case 'clearPineapLog':
+                $this->clearPineapLog();
+                break;
+
+            case 'getPineapLogLocation':
+                $this->getPineapLogLocation();
+                break;
+
+            case 'setPineapLogLocation':
+                $this->setPineapLogLocation();
+                break;
+
+            case 'downloadPineapLog':
+                $this->downloadPineapLog();
+                break;
+        }
+    }
+
+    private function downloadPineapLog()
+    {
+        $dbLocation = $this->uciGet("pineap.@config[0].hostapd_db_path");
+        $db = new DatabaseConnection($dbLocation);
+        $rows = $db->query("SELECT * FROM log ORDER BY updated_at ASC;");
+        $logFile = fopen("/tmp/pineap.log", 'w');
+        $count = "-";
+        foreach ($rows as $row) {
+            switch ($row['log_type']) {
+                case 0:
+                    $type = "Probe Request";
+                    $count = $row['dups'];
+                    break;
+                case 1:
+                    $type = "Association";
+                    break;
+                case 2:
+                    $type = "De-association";
+                    break;
+                default:
+                    $type = "";
+                    break;
+            }
+            fwrite($logFile, "${row['created_at']},\t${type},\t${row['mac']},\t${row['ssid']},\t${count}\n");
+        }
+        fclose($logFile);
+        $this->response = array("download" => $this->downloadFile('/tmp/pineap.log'));
+    }
+
+    private function getSyslog()
+    {
+        exec("logread", $syslogOutput);
+        $this->response = implode("\n", $syslogOutput);
+    }
+
+    private function getDmesg()
+    {
+        exec("dmesg", $dmesgOutput);
+        $this->response = implode("\n", $dmesgOutput);
+    }
+
+    private function getReportingLog()
+    {
+        touch('/tmp/reporting.log');
+        $this->streamFunction = function () {
+            $fp = fopen('/tmp/reporting.log', 'r');
+            while (($buf = fgets($fp)) !== false) {
+                echo $buf;
+            }
+            fclose($fp);
+        };
+    }
+
+    private function getPineapLog()
+    {
+        $dbLocation = $this->uciGet("pineap.@config[0].hostapd_db_path");
+        $db = new DatabaseConnection($dbLocation);
+        $rows = $db->query("SELECT * FROM log ORDER BY updated_at DESC;");
+        $this->response = array("pineap_log" => $rows);
+    }
+
+    private function clearPineapLog()
+    {
+        $dbLocation = $this->uciGet("pineap.@config[0].hostapd_db_path");
+        $db = new DatabaseConnection($dbLocation);
+        $db->exec("DELETE FROM log;");
+        $this->response = array('success' => true);
+    }
+
+    private function getPineapLogLocation()
+    {
+        $dbBasePath = dirname($this->uciGet("pineap.@config[0].hostapd_db_path"));
+        $this->response = array('location' => $dbBasePath . "/");
+    }
+
+    private function setPineapLogLocation()
+    {
+        $dbLocation = dirname($this->request->location . '/fake_file');
+        $this->uciSet("pineap.@config[0].hostapd_db_path", $dbLocation . '/log.db');
+        $this->response = array('success' => true);
+    }
+}

+ 193 - 0
src/pineapple/modules/Logging/js/module.js

@@ -0,0 +1,193 @@
+registerController('PineAPLogController', ['$api', '$scope', '$timeout', function($api, $scope, $timeout) {
+    $scope.log = [];
+    $scope.mac = '';
+    $scope.ssid = '';
+    $scope.logLocation = '';
+    $scope.locationModified = false;
+    $scope.orderByName = 'log_time';
+    $scope.reverseSort = true;
+
+    $scope.checkboxOptions = {
+        probes: true,
+        associations: true,
+        removeDuplicates: false
+    };
+
+
+    $scope.refreshLog = (function() {
+        $scope.log = [];
+        $api.request({
+            module: 'Logging',
+            action: 'getPineapLog'
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.log = response.pineap_log;
+                $scope.applyFilter();
+                annotateMacs();
+            }
+        });
+    });
+
+    $scope.downloadLog = (function() {
+        $api.request({
+            module: 'Logging',
+            action: 'downloadPineapLog'
+        }, function(response) {
+            if (response.error === undefined) {
+                window.location = '/api/?download=' + response.download;
+            }
+        });
+    });
+
+    $scope.getPineapLogLocation = (function () {
+        $api.request({
+            module: 'Logging',
+            action: 'getPineapLogLocation'
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.logLocation = response.location;
+            }
+        });
+    });
+
+    $scope.setPineapLogLocation = (function () {
+        $api.request({
+            module: 'Logging',
+            action: 'setPineapLogLocation',
+            location: $scope.logLocation
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.locationModified = true;
+                $timeout(function() {
+                    $scope.locationModified = false;
+                }, 3000);
+            }
+        });
+    });
+
+    $scope.checkMatch = (function(text, filter) {
+        if (filter.trim() === '') {
+            return true;
+        }
+        if (text.toLowerCase().indexOf(filter.toLowerCase()) !== -1) {
+            return true;
+        }
+        try {
+            var re = new RegExp(filter);
+            if (text.match(re) !== null) {
+                return true;
+            }
+        }
+        catch (err) {}
+        return false;
+    });
+
+    $scope.applyFilter = (function() {
+        var hashArray = [];
+        $.each($scope.log, function(i, value){
+            if (value.log_time !== '') {
+                value.hidden = false;
+                if ($scope.checkboxOptions.removeDuplicates) {
+                    var index = value.ssid + value.log_type + value.mac;
+                    if (hashArray[index] === undefined) {
+                        hashArray[index] = true;
+                    } else {
+                        value.hidden = true;
+                        return true;
+                    }
+                }
+
+                if (!$scope.checkboxOptions.probes) {
+                    if (value.log_type === 0) {
+                        value.hidden = true;
+                    }
+                }
+                if (!$scope.checkboxOptions.associations) {
+                    if (value.log_type === 1 || value.log_type === 2) {
+                        value.hidden = true;
+                    }
+                }
+
+                if (!$scope.checkMatch(value.mac, $scope.mac)) {
+                    value.hidden = true;
+                } else if (!$scope.checkMatch(value.ssid, $scope.ssid)) {
+                    value.hidden = true;
+                }
+            }
+        });
+    });
+
+    $scope.clearFilter = (function() {
+        $scope.mac = '';
+        $scope.ssid = '';
+        $scope.checkboxOptions.probes = true;
+        $scope.checkboxOptions.associations = true;
+
+        $scope.applyFilter();
+    });
+
+    $scope.clearLog = (function() {
+        $api.request({
+            module: 'Logging',
+            action: 'clearPineapLog'
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.log = [];
+            }
+        });
+    });
+
+    $scope.getPineapLogLocation();
+    $scope.refreshLog();
+}]);
+
+registerController('SyslogController', ['$api', '$scope', function($api, $scope) {
+    $scope.syslog = 'Loading..';
+
+    $scope.refreshLog = (function() {
+        $api.request({
+            module: 'Logging',
+            action: 'getSyslog'
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.syslog = response;
+            }
+        })
+    });
+
+    $scope.refreshLog();
+}]);
+
+registerController('DmesgController', ['$api', '$scope', function($api, $scope) {
+    $scope.dmesg = 'Loading..';
+
+    $scope.refreshLog = (function() {
+        $api.request({
+            module: 'Logging',
+            action: 'getDmesg'
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.dmesg = response;
+            }
+        })
+    });
+
+    $scope.refreshLog();
+}]);
+
+registerController('ReportingLogController', ['$api', '$scope', function($api, $scope) {
+    $scope.reportingLog = "";
+
+    $scope.refreshLog = (function() {
+        $api.request({
+            module: 'Logging',
+            action: 'getReportingLog'
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.reportingLog = response;
+            }
+        })
+    });
+
+    $scope.refreshLog();
+}]);

+ 104 - 0
src/pineapple/modules/Logging/module.html

@@ -0,0 +1,104 @@
+<div class="panel-group" id="accordion">
+    <div class="panel panel-default" ng-controller="PineAPLogController">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#PineAP" data-parent="#accordion">
+            <h4 class="panel-title">PineAP Log</h4>
+        </div>
+        <div id="PineAP" class="panel-collapse collapse in">
+            <div class="panel-body">
+                <div class="input-group">
+                    <div class="checkbox">
+                        <label><input type="checkbox" ng-model="checkboxOptions.probes">Display Probes</label>
+                    </div>
+                    <div class="checkbox">
+                        <label><input type="checkbox" ng-model="checkboxOptions.associations">Display (De)Associations</label>
+                    </div>
+                    <div class="checkbox">
+                        <label><input type="checkbox" ng-model="checkboxOptions.removeDuplicates">Remove Duplicates</label>
+                    </div>
+                </div>
+                <div class="row">
+                    <div class="col-md-5">
+                        <div class="input-group">
+                            <span class="input-group-addon">SSID</span>
+                            <input type="text" class="form-control" placeholder="SSID" ng-model="ssid">
+                        </div>
+                        <div class="input-group">
+                            <span class="input-group-addon">MAC</span>
+                            <input type="text" class="form-control" placeholder="00:11:22:33:44:55" ng-model="mac">
+                        </div>
+                        <div class="input-group">
+                            <span class="input-group-addon">Location</span>
+                            <input type="text" class="form-control" placeholder="SSID" ng-model="logLocation">
+                            <span class="input-group-btn">
+                                <button class="btn btn-default" type="button" ng-click="setPineapLogLocation()">Save</button>
+                            </span>
+                        </div>
+                        <p class="alert well-sm alert-success" ng-show="locationModified">PineAP log location saved. You must reboot the device for changes to take effect.</p>
+                    </div>
+                </div>
+                <br/>
+                <button class="btn btn-default" ng-click="applyFilter()">Apply Filter</button>
+                <button class="btn btn-default" ng-click="clearFilter()">Clear Filter</button>
+                <button class="btn btn-default" ng-click="refreshLog()">Refresh Log</button>
+                <button class="btn btn-default" ng-click="clearLog()">Clear Log</button>
+                <button class="btn btn-default" ng-click="downloadLog()">Download Log</button>
+            </div>
+            <div class="table-responsive">
+                <table class="table table-striped table-bordered table-hover" ng-hide="(log.length == 0)">
+                    <thead>
+                        <tr class="default-cursor">
+                            <th ng-click="orderByName='log_time'; reverseSort = !reverseSort">Time <span ng-show="orderByName=='log_time'"><span class="caret" ng-show="reverseSort"></span><span class="caret caret-reversed" ng-show="!reverseSort"></span></span></th>
+                            <th ng-click="orderByName='log_type'; reverseSort = !reverseSort">Event <span ng-show="orderByName=='log_type'"><span class="caret" ng-show="reverseSort"></span><span class="caret caret-reversed" ng-show="!reverseSort"></span></span></th>
+                            <th ng-click="orderByName='mac'; reverseSort = !reverseSort">MAC <span ng-show="orderByName=='mac'"><span class="caret" ng-show="reverseSort"></span><span class="caret caret-reversed" ng-show="!reverseSort"></span></span></th>
+                            <th ng-click="orderByName='ssid'; reverseSort = !reverseSort">SSID <span ng-show="orderByName=='ssid'"><span class="caret" ng-show="reverseSort"></span><span class="caret caret-reversed" ng-show="!reverseSort"></span></span></th>
+                            <th ng-click="orderByName='log_count'; reverseSort = !reverseSort">Count <span ng-show="orderByName=='log_count'"><span class="caret" ng-show="reverseSort"></span><span class="caret caret-reversed" ng-show="!reverseSort"></span></span></th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr ng-repeat="entry in log|orderBy:orderByName:reverseSort" ng-if="(entry != '' && entry.hidden != true)">
+                            <td>{{ entry.created_at | timesinceepoch }}</td>
+                            <td ng-if="entry.log_type == 0">Probe Request</td>
+                            <td ng-if="entry.log_type == 1">Association</td>
+                            <td ng-if="entry.log_type == 2">De-asscoation</td>
+                            <td class="autoselect"><hook-button hook="mac" content="entry.mac"></hook-button> {{ entry.mac }}</td>
+                            <td class="autoselect"><hook-button hook="ssid" content="entry.ssid"></hook-button> {{ entry.ssid }}</td>
+                            <td ng-if="entry.log_type == 0">{{ entry.dups}}</td>
+                            <td ng-if="entry.log_type != 0">-</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </div>
+    <div class="panel panel-default" ng-controller="SyslogController">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#Syslog" data-parent="#accordion">
+            <h4 class="panel-title">System Log</h4>
+        </div>
+        <div id="Syslog" class="panel-collapse collapse">
+            <div class="panel-body">
+                <pre class="scrollable-pre log-pre">{{ syslog }}</pre>
+            </div>
+        </div>
+    </div>
+    <div class="panel panel-default" ng-controller="DmesgController">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#Dmesg" data-parent="#accordion">
+            <h4 class="panel-title">Dmesg</h4>
+        </div>
+        <div id="Dmesg" class="panel-collapse collapse">
+            <div class="panel-body">
+                <pre class="scrollable-pre log-pre">{{ dmesg }}</pre>
+            </div>
+        </div>
+    </div>
+    <div class="panel panel-default" ng-controller="ReportingLogController">
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#Reporting" data-parent="#accordion">
+            <h4 class="panel-title">Reporting Log</h4>
+        </div>
+        <div id="Reporting" class="panel-collapse collapse">
+            <div class="panel-body">
+                <p ng-show="reportingLog.trim() == ''">No log entries found yet.</p>
+                <pre class="scrollable-pre log-pre" ng-hide="reportingLog.trim() == ''">{{ reportingLog }}</pre>
+            </div>
+        </div>
+    </div>
+</div>

+ 12 - 0
src/pineapple/modules/Logging/module.info

@@ -0,0 +1,12 @@
+{
+    "author": "Hak5",
+    "description": "Manage and view various logs.",
+    "devices": [
+        "nano",
+        "tetra"
+    ],
+    "system": true,
+    "title": "Logging",
+    "version": "1.1",
+    "index": 8
+}

+ 25 - 0
src/pineapple/modules/Logging/module_icon.svg

@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 256 256" style="enable-background:new 0 0 256 256;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#808080;}
+</style>
+<g>
+	<path class="st0" d="M195.4,41.6H60.6c-3.3,0-5.9,2.4-5.9,5.3c0,2.9,2.7,5.3,5.9,5.3h134.7c3.3,0,5.9-2.4,5.9-5.3
+		C201.3,44,198.6,41.6,195.4,41.6z"/>
+	<path class="st0" d="M195.4,66.2H60.6c-3.3,0-5.9,2.4-5.9,5.3c0,2.9,2.7,5.3,5.9,5.3h134.7c3.3,0,5.9-2.4,5.9-5.3
+		C201.3,68.5,198.6,66.2,195.4,66.2z"/>
+	<path class="st0" d="M195.4,90.7H60.6c-3.3,0-5.9,2.4-5.9,5.3c0,2.9,2.7,5.3,5.9,5.3h134.7c3.3,0,5.9-2.4,5.9-5.3
+		C201.3,93,198.6,90.7,195.4,90.7z"/>
+	<path class="st0" d="M195.4,115.2H60.6c-3.3,0-5.9,2.4-5.9,5.3c0,2.9,2.7,5.3,5.9,5.3h134.7c3.3,0,5.9-2.4,5.9-5.3
+		C201.3,117.6,198.6,115.2,195.4,115.2z"/>
+	<path class="st0" d="M216,3H40c-9.7,0-17.6,7.9-17.6,17.6v214.8c0,9.7,7.9,17.6,17.6,17.6H216c9.7,0,17.6-7.9,17.6-17.6V20.6
+		C233.6,10.9,225.8,3,216,3z M221.8,235.4c0,3.2-2.6,5.7-5.7,5.7H40c-3.2,0-5.7-2.6-5.7-5.7V20.6c0-3.2,2.6-5.7,5.7-5.7H216
+		c3.2,0,5.7,2.6,5.7,5.7V235.4z"/>
+	<path class="st0" d="M195.4,141H60.6c-3.3,0-5.9,2.4-5.9,5.3c0,2.9,2.7,5.3,5.9,5.3h134.7c3.3,0,5.9-2.4,5.9-5.3
+		C201.3,143.4,198.6,141,195.4,141z"/>
+	<path class="st0" d="M138.9,166.7H60.6c-3.3,0-5.9,2.4-5.9,5.3c0,2.9,2.7,5.3,5.9,5.3h78.3c3.3,0,5.9-2.4,5.9-5.3
+		C144.8,169,142.2,166.7,138.9,166.7z"/>
+</g>
+</svg>

+ 196 - 0
src/pineapple/modules/ModuleManager/api/module.php

@@ -0,0 +1,196 @@
+<?php namespace pineapple;
+
+class ModuleManager extends SystemModule
+{
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'getAvailableModules':
+                $this->getAvailableModules();
+                break;
+
+            case 'getInstalledModules':
+                $this->getInstalledModules();
+                break;
+
+            case 'installModule':
+                $this->installModule();
+                break;
+
+            case 'downloadModule':
+                $this->downloadModule();
+                break;
+
+            case 'checkDestination':
+                $this->checkDestination();
+                break;
+
+            case 'removeModule':
+                $this->removeModule();
+                break;
+
+            case 'downloadStatus':
+                $this->downloadStatus();
+                break;
+
+            case 'installStatus':
+                $this->installStatus();
+                break;
+
+            case 'restoreSDcardModules':
+                if ($this->sdReaderPresent()) {
+                    $this->restoreSDcardModules();
+                }
+                break;
+        }
+    }
+
+    private function getAvailableModules()
+    {
+        $device = $this->getDevice();
+        $moduleData = @file_get_contents("https://www.wifipineapple.com/{$device}/modules");
+
+        if ($moduleData !== false) {
+            $moduleData = json_decode($moduleData);
+            if (json_last_error() === JSON_ERROR_NONE) {
+                $this->response = array('availableModules' => $moduleData);
+            }
+        } else {
+            $this->error = 'Error connecting to WiFiPineapple.com. Please check your connection.';
+        }
+    }
+
+    private function getInstalledModules()
+    {
+        $modules = array();
+        $modulesDirectories = scandir('/pineapple/modules');
+        foreach ($modulesDirectories as $moduleDirectory) {
+            if ($moduleDirectory[0] === ".") {
+                continue;
+            }
+
+            if (file_exists("/pineapple/modules/{$moduleDirectory}/module.info")) {
+                $moduleData = json_decode(file_get_contents("/pineapple/modules/{$moduleDirectory}/module.info"));
+                
+                if (json_last_error() !== JSON_ERROR_NONE) {
+                    continue;
+                }
+
+                $module = array();
+                $module['title'] = $moduleData->title;
+                $module['author'] = $moduleData->author;
+                $module['version'] = $moduleData->version;
+                $module['description'] = $moduleData->description;
+                $module['size'] = exec("du -sh /pineapple/modules/$moduleDirectory/ | awk '{print $1;}'");
+                $module['checksum'] = $moduleData->checksum;
+                if (isset($moduleData->system)) {
+                    $module['type'] = "System";
+                } elseif (isset($moduleData->cliOnly)) {
+                    $module['type'] = "CLI";
+                } else {
+                    $module['type'] = "GUI";
+                }
+
+                $modules[$moduleDirectory] = $module;
+            }
+        }
+        $this->response = array("installedModules" => $modules);
+    }
+
+    private function downloadModule()
+    {
+        @unlink('/tmp/moduleDownloaded');
+
+        if ($this->request->destination === 'sd') {
+            @mkdir('/sd/tmp/');
+            $dest = '/sd/tmp/';
+        } else {
+            $dest = '/tmp/';
+        }
+
+        $device = $this->getDevice();
+        $this->execBackground("wget 'https://www.wifipineapple.com/{$device}/modules/{$this->request->moduleName}' -O {$dest}{$this->request->moduleName}.tar.gz && touch /tmp/moduleDownloaded");
+        $this->response = array('success' => true);
+    }
+
+    private function downloadStatus()
+    {
+        if (file_exists('/tmp/moduleDownloaded')) {
+            if ($this->request->destination === 'sd') {
+                $dest = '/sd/tmp/';
+            } else {
+                $dest = '/tmp/';
+            }
+
+            if (hash_file('sha256', "{$dest}{$this->request->moduleName}.tar.gz") == $this->request->checksum) {
+                $this->response = array('success' => true);
+                return;
+            }
+        }
+        $this->response = array('success' => false);
+    }
+
+    private function installModule()
+    {
+        @unlink('/tmp/moduleInstalled');
+        $this->removeModule();
+
+        if ($this->request->destination === 'sd') {
+            @mkdir('/sd/modules/');
+            $dest = '/sd/tmp/';
+            $installDest = '/sd/modules/';
+            exec("ln -s /sd/modules/{$this->request->moduleName} /pineapple/modules/{$this->request->moduleName}");
+        } else {
+            $dest = '/tmp/';
+            $installDest = '/pineapple/modules/';
+        }
+
+        $this->execBackground("tar -xzvC {$installDest} -f {$dest}{$this->request->moduleName}.tar.gz && rm {$dest}{$this->request->moduleName}.tar.gz && touch /tmp/moduleInstalled");
+        $this->response = array('success' => true);
+    }
+
+    private function installStatus()
+    {
+        $this->response = array('success' => file_exists('/tmp/moduleInstalled'));
+    }
+
+    private function checkDestination()
+    {
+        $responseArray = array('module' => $this->request->name, 'internal' => false, 'sd' => false);
+
+        if (disk_free_space('/') > ($this->request->size + 150000)) {
+            $responseArray['internal'] = true;
+        }
+
+        if ($this->isSDAvailable()) {
+            $responseArray['sd'] = true;
+        }
+
+        $this->response = $responseArray;
+    }
+
+    private function removeModule()
+    {
+        if (is_link("/pineapple/modules/{$this->request->moduleName}")) {
+            @unlink("/pineapple/modules/{$this->request->moduleName}");
+            exec("rm -rf /sd/modules/{$this->request->moduleName}");
+        } else {
+            exec("rm -rf /pineapple/modules/{$this->request->moduleName}");
+        }
+
+        $this->response = array('success' => true);
+    }
+
+    private function restoreSDcardModules()
+    {
+        $restored = false;
+        $sdcardModules = @scandir('/sd/modules/');
+        foreach ($sdcardModules as $module) {
+            if ($module[0] != '.' && !file_exists("/pineapple/modules/{$module}")) {
+                $restored = true;
+                exec("ln -s /sd/modules/{$module} /pineapple/modules/{$module}");
+            }
+        }
+        $this->response = array("restored" => $restored);
+    }
+}

+ 128 - 0
src/pineapple/modules/ModuleManager/js/module.js

@@ -0,0 +1,128 @@
+registerController("ModuleManagerController", ['$api', '$scope', '$timeout', '$interval', '$templateCache', '$rootScope', function($api, $scope, $timeout, $interval, $templateCache, $rootScope){
+    $rootScope.availableModules = [];
+    $rootScope.installedModules = [];
+    $scope.installedModule = "";
+    $scope.removedModule = "";
+    $scope.gotAvailableModules = false;
+    $scope.connectionError = false;
+    $scope.selectedModule = false;
+    $scope.downloading = false;
+    $scope.installing = false;
+    $scope.linking = true;
+    $scope.device = undefined;
+
+    $scope.getDevice = (function() {
+        $api.request({
+            module: "Configuration",
+            action: "getDevice"
+        }, function(response) {
+            $scope.device = response.device;
+        });
+    });
+    $scope.getDevice();
+
+    $scope.getAvailableModules = (function() {
+        $scope.loading = true;
+        $api.request({
+            module: "ModuleManager",
+            action: "getAvailableModules"
+        }, function(response) {
+            $scope.loading = false;
+            if (response.error === undefined) {
+                $rootScope.availableModules = response.availableModules;
+                $scope.compareModuleLists();
+                $scope.gotAvailableModules = true;
+                $scope.connectionError = false;
+            } else {
+                $scope.connectionError = response.error;
+            }
+        });
+    });
+
+    $scope.getInstalledModules = (function() {
+        $api.request({
+            module: "ModuleManager",
+            action: "getInstalledModules"
+        }, function(response) {
+            $rootScope.installedModules = response.installedModules;
+            if ($scope.gotAvailableModules) {
+                $scope.compareModuleLists();
+            }
+        });
+    });
+
+    $scope.compareModuleLists = (function() {
+        angular.forEach($rootScope.availableModules, function(module, moduleName){
+            if ($rootScope.installedModules[moduleName] === undefined){
+                module['installable'] = true;
+            } else if ($rootScope.availableModules[moduleName].version <= $rootScope.installedModules[moduleName].version) {
+                module['installed'] = true;
+            }
+        });
+    });
+
+    $scope.checkDestination = (function(moduleName, moduleSize) {
+        $(window).scrollTop(0);
+
+        if ($rootScope.installedModules[moduleName] !== undefined && $rootScope.installedModules[moduleName]['type'] === 'System') {
+            $scope.selectedModule = {module: moduleName, internal: true, sd: false};
+            return;
+        }
+
+        if ($scope.device === 'tetra') {
+            $scope.selectedModule = {module: moduleName, internal: true, sd: false};
+            $scope.downloadModule('internal');
+            return;
+        }
+
+        $api.request({
+            module: 'ModuleManager',
+            action: 'checkDestination',
+            name: moduleName,
+            size: moduleSize
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.selectedModule = response;
+            }
+        });
+    });
+
+    $scope.removeModule = (function(name) {
+        $api.request({
+            module: 'ModuleManager',
+            action: 'removeModule',
+            moduleName: name
+        }, function(response) {
+            if (response.success === true) {
+                $scope.getInstalledModules();
+                $scope.removedModule = true;
+                $api.reloadNavbar();
+                $timeout(function(){
+                    $scope.removedModule = false;
+                }, 2000);
+            }
+        });
+    });
+
+    $scope.restoreSDcardModules = (function() {
+        $api.request({
+            module: 'ModuleManager',
+            action: 'restoreSDcardModules'
+        }, function(response) {
+            if (response.restored === true) {
+                $scope.restoreSDcardModules();
+            } else {
+                $api.reloadNavbar();
+                $scope.getInstalledModules();
+                $scope.linking = false;
+            }
+        });
+    });
+
+    if ($scope.device === 'nano') {
+        $scope.restoreSDcardModules();
+    } else {
+        $scope.linking = false;
+    }
+    $scope.getInstalledModules();
+}]);

+ 121 - 0
src/pineapple/modules/ModuleManager/module.html

@@ -0,0 +1,121 @@
+<div ng-controller="ModuleManagerController">
+    <div class="row" ng-hide="linking">
+        <div class="col-sm-12">
+
+            <p class="alert well-sm alert-success" ng-show="installedModule">Successfully Installed Module</p>
+            <p class="alert well-sm alert-success" ng-show="removedModule">Successfully Removed Module</p>
+
+            <div ng-hide="gotAvailableModules">
+                <button class="btn btn-default" ng-click="getAvailableModules()" ng-disabled="loading">Get Modules from Hak5 Community Repositories</button>
+                <img src="img/throbber.gif" ng-show="loading">
+                <br/><br/>
+            </div>
+            <div class="alert well-sm alert-danger" ng-show="connectionError">
+                {{ connectionError }}
+            </div>
+
+            <div class="panel panel-default" ng-show="gotAvailableModules">
+                <div class="panel-heading">
+                    <h3 class="panel-title">Available Modules <button class="btn btn-default btn-xs btn-fixed-length pull-right" ng-click="getAvailableModules()">Refresh</button></h3>
+                </div>
+                <div class="table-responsive table-dropdown">
+                    <table class="table module-table">
+                        <thead>
+                            <tr>
+                                <th>Module</th>
+                                <th>Version</th>
+                                <th>Description</th>
+                                <th>Author</th>
+                                <th>Size</th>
+                                <th>Type</th>
+                                <th>Action</th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            <tr ng-repeat="(moduleName, module) in availableModules" ng-if="module.installed === undefined">
+                                <td>
+                                    {{ module['title'] }}
+                                </td>
+                                <td>
+                                    {{ module['version'] }}
+                                </td>
+                                <td>
+                                    {{ module['description'] }}
+                                </td>
+                                <td>
+                                    {{ module['author'] }}
+                                </td>
+                                <td>
+                                    {{ (module['size']/1024).toFixed(2) }}K
+                                </td>
+                                <td>
+                                    {{ module['type'] }}
+                                </td>
+                                <td>
+                                    <update-button ng-hide="module.installable" content="{name: moduleName, module: module, updating: true}"></update-button>
+                                    <install-button ng-show="module.installable" content="{name: moduleName, module: module}"></install-button>
+                                </td>
+                            </tr>
+                        </tbody>
+                    </table>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="row">
+        <div class="col-sm-12 text-center" ng-if="linking">
+            <img src="img/throbber.gif">
+        </div>
+    </div>
+
+    <div class="row" ng-hide="linking">
+        <div class="col-sm-12">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <h3 class="panel-title">Installed Modules</h3>
+                </div>
+                <div class="table-responsive table-dropdown">
+                    <table class="table">
+                        <thead>
+                            <tr>
+                                <th>Module</th>
+                                <th>Version</th>
+                                <th>Description</th>
+                                <th>Size</th>
+                                <th>Author</th>
+                                <th>Type</th>
+                                <th>Action</th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            <tr ng-repeat="(moduleName, module) in installedModules" ng-hide="(module['type'] == 'System')">
+                                <td>
+                                    {{ module['title'] }}
+                                </td>
+                                <td>
+                                    {{ module['version'] }}
+                                </td>
+                                <td>
+                                    {{ module['description'] }}
+                                </td>
+                                <td>
+                                    {{ module['size'] }}
+                                </td>
+                                <td>
+                                    {{ module['author'] }}
+                                </td>
+                                <td>
+                                    {{ module['type'] }}
+                                </td>
+                                <td>
+                                <button type="button" class="btn btn-danger btn-xs btn-fixed-length" ng-hide="(module['type'] === 'System')" ng-click="removeModule(moduleName)">Remove</button>
+                                </td>
+                            </tr>
+                        </tbody>
+                    </table>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>

+ 11 - 0
src/pineapple/modules/ModuleManager/module.info

@@ -0,0 +1,11 @@
+{
+    "author": "Hak5",
+    "description": "Download new community modules, and system module updates.",
+    "devices": [
+        "nano",
+        "tetra"
+    ],
+    "system": true,
+    "title": "Modules",
+    "version": "1.2"
+}

+ 15 - 0
src/pineapple/modules/ModuleManager/module_icon.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 256 256" style="enable-background:new 0 0 256 256;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#808080;}
+</style>
+<g>
+	<path class="st0" d="M181.4,242.5c-3,0-6.1-0.4-9-1.3l-2.2-0.6l-46.5-25.2l-24.2,11.5l-26.2,12.5l-2,0.5c-2.3,0.6-4.8,0.9-7.2,0.9
+		c-15.2,0-27.2-11.8-27.4-26.9l0-1.9l11-52.9l-37.2-37l-0.3-0.3c-7.1-7.7-9.3-18.1-5.5-27.1c3.6-8.7,12.1-14.7,22.6-15.9l52.3-6
+		l24.8-45.4l0.1-0.2c4.8-8.4,14.3-13.6,24.6-13.6c11.4,0,21.3,6.3,25.2,15.9l9,19l12.8,26.9l54.2,9.1c9.5,1.3,17.5,7.2,20.9,15.8
+		c3.4,8.5,1.6,17.7-4.7,24l-0.4,0.4L203,163l7.5,51.2l0,1.5C210.1,230.7,197.3,242.5,181.4,242.5
+		C181.4,242.5,181.4,242.5,181.4,242.5z"/>
+</g>
+</svg>

+ 50 - 0
src/pineapple/modules/Networking/api/AccessPoint.php

@@ -0,0 +1,50 @@
+<?php namespace helper;
+
+class AccessPoint
+{
+
+    public function saveAPConfig($apConfig)
+    {
+        uciSet('wireless.radio0.channel', $apConfig->selectedChannel);
+        $device = getDevice();
+        if ($apConfig->selectedChannel > 14 && $device == 'tetra') {
+            uciSet('wireless.radio0.hwmode', '11n');
+        }
+
+        uciSet('wireless.@wifi-iface[0].ssid', $apConfig->openSSID);
+        uciSet('wireless.@wifi-iface[0].hidden', $apConfig->hideOpenAP);
+        uciSet('wireless.@wifi-iface[0].maxassoc', $apConfig->maxClients);
+        uciSet('wireless.@wifi-iface[1].ssid', $apConfig->managementSSID);
+        uciSet('wireless.@wifi-iface[1].key', $apConfig->managementKey);
+        uciSet('wireless.@wifi-iface[1].disabled', $apConfig->disableManagementAP);
+        uciSet('wireless.@wifi-iface[1].hidden', $apConfig->hideManagementAP);
+        execBackground('wifi');
+        return array("success" => true);
+    }
+
+    public function getAPConfig()
+    {
+        exec("iwinfo phy0 freqlist", $output);
+        preg_match_all("/\(Channel (\d+)\)$/m", implode("\n", $output), $channelList);
+
+        // Remove radar detection channels
+        $channels = array();
+        foreach ($channelList[1] as $channel) {
+            if ((int)$channel < 52 || (int)$channel > 140) {
+                $channels[] = $channel;
+            }
+        }
+
+        return array(
+            "selectedChannel" => uciGet("wireless.radio0.channel"),
+            "availableChannels" => $channels,
+            "openSSID" => uciGet("wireless.@wifi-iface[0].ssid"),
+            "maxClients" => uciGet("wireless.@wifi-iface[0].maxassoc"),
+            "hideOpenAP" => uciGet("wireless.@wifi-iface[0].hidden"),
+            "managementSSID" => uciGet("wireless.@wifi-iface[1].ssid"),
+            "managementKey" => uciGet("wireless.@wifi-iface[1].key"),
+            "disableManagementAP" => uciGet("wireless.@wifi-iface[1].disabled"),
+            "hideManagementAP" => uciGet("wireless.@wifi-iface[1].hidden")
+        );
+    }
+}

+ 173 - 0
src/pineapple/modules/Networking/api/ClientMode.php

@@ -0,0 +1,173 @@
+<?php namespace helper;
+
+class ClientMode
+{
+
+    public function scanForNetworks($interface, $uciID, $radio)
+    {
+        $interface = escapeshellarg($interface);
+        if (substr($interface, -4, -1) === "mon") {
+            if ($interface == "'wlan1mon'") {
+                exec("/etc/init.d/pineapd stop");
+            }
+            exec("airmon-ng stop {$interface}");
+            $interface = substr($interface, 0, -4) . "'";
+            exec("iw dev {$interface} scan &> /dev/null");
+        }
+
+        $device = getDevice();
+        if (uciGet("wireless.@wifi-iface[{$uciID}].network") === 'wwan') {
+            uciSet("wireless.@wifi-iface[{$uciID}].network", 'lan');
+            exec("wifi up $radio");
+            sleep(2);
+        }
+
+        exec("iwinfo {$interface} scan", $apScan);
+
+        if ($apScan[0] === 'No scan results') {
+            return null;
+        }
+
+        $apArray = preg_split("/^Cell/m", implode("\n", $apScan));
+
+        $returnArray = array();
+        foreach ($apArray as $apData) {
+            $apData = explode("\n", $apData);
+            $accessPoint = array();
+            $accessPoint['mac'] = substr($apData[0], -17);
+            $accessPoint['ssid'] = substr(trim($apData[1]), 8, -1);
+            if (mb_detect_encoding($accessPoint['ssid'], "auto") === false) {
+                continue;
+            }
+
+            $base = $device == 'tetra' ? 23 : -2;
+            $accessPoint['channel'] = intval(substr(trim($apData[2]), $base));
+
+            $signalString = explode("  ", trim($apData[3]));
+            $accessPoint['signal'] = substr($signalString[0], 8);
+            $accessPoint['quality'] = substr($signalString[1], 9);
+
+            $security = substr(trim($apData[4]), 12);
+            if ($security === "none") {
+                $accessPoint['security'] = "Open";
+            } else {
+                $accessPoint['security'] = $security;
+            }
+
+            if ($accessPoint['mac'] && trim($apData[1]) !== "ESSID: unknown") {
+                array_push($returnArray, $accessPoint);
+            }
+        }
+
+        return $returnArray;
+    }
+
+    public function connectToAP($uciID, $ap, $key, $radioID)
+    {
+        exec('[ ! -z "$(wifi config)" ] && wifi config > /etc/config/wireless');
+
+        $security = $ap->security;
+        switch ($security) {
+            case 'Open':
+                $encryption = "none";
+                break;
+
+            case 'WPA (TKIP)':
+            case 'WPA PSK (TKIP)':
+                $encryption = "psk+tkip";
+                break;
+
+            case 'WPA (CCMP)':
+            case 'WPA PSK (CCMP)':
+                $encryption = "psk+ccmp";
+                break;
+
+            case 'WPA (TKIP, CCMP)':
+            case 'WPA PSK (TKIP, CCMP)':
+                $encryption = "psk+tkip+ccmp";
+                break;
+
+            case 'WPA2 (TKIP)':
+            case 'WPA2 PSK (TKIP)':
+                $encryption = "psk2+tkip";
+                break;
+
+            case 'WPA2 (CCMP)':
+            case 'WPA2 PSK (CCMP)':
+                $encryption = "psk2+ccmp";
+                break;
+
+            case 'WPA2 (TKIP, CCMP)':
+            case 'WPA2 PSK (TKIP, CCMP)':
+                $encryption = "psk2+ccmp+tkip";
+                break;
+
+            case 'mixed WPA/WPA2 (TKIP)':
+            case 'mixed WPA/WPA2 PSK (TKIP)':
+                $encryption = "psk-mixed+tkip";
+                break;
+
+            case 'mixed WPA/WPA2 (CCMP)':
+            case 'mixed WPA/WPA2 PSK (CCMP)':
+                $encryption = "psk-mixed+ccmp";
+                break;
+
+            case 'mixed WPA/WPA2 (TKIP, CCMP)':
+            case 'mixed WPA/WPA2 PSK (TKIP, CCMP)':
+                $encryption = "psk-mixed+ccmp+tkip";
+                break;
+
+            default:
+                $encryption = "";
+        }
+
+        uciSet("wireless.@wifi-iface[{$uciID}].network", 'wwan');
+        uciSet("wireless.@wifi-iface[{$uciID}].mode", 'sta');
+        uciSet("wireless.@wifi-iface[{$uciID}].ssid", $ap->ssid);
+        uciSet("wireless.@wifi-iface[{$uciID}].encryption", $encryption);
+        uciSet("wireless.@wifi-iface[{$uciID}].key", $key);
+
+        if ($radioID === false) {
+            execBackground("wifi");
+        } else {
+            execBackground("wifi reload {$radioID}");
+            execBackground("wifi up {$radioID}");
+        }
+
+        return array("success" => true);
+    }
+
+    public function checkConnection()
+    {
+        $connection = exec('iwconfig 2>&1 | grep ESSID:\"');
+        if (trim($connection)) {
+            $interface = explode(" ", $connection)[0];
+
+            $ssidString = substr($connection, strpos($connection, 'ESSID:'));
+            $ssid = substr($ssidString, 7, -1);
+            $ip = exec("ifconfig " . escapeshellarg($interface) . " | grep -m 1 inet | awk '{print \$2}' | awk -F':' '{print \$2}'");
+
+
+            return array("connected" => true, "interface" => $interface, "ssid" => $ssid, "ip" => $ip);
+        } else {
+            return array("connected" => false);
+        }
+    }
+
+    public function disconnect($uciID, $radioID)
+    {
+        uciSet("wireless.@wifi-iface[{$uciID}].network", 'lan');
+        uciSet("wireless.@wifi-iface[{$uciID}].ssid", '');
+        uciSet("wireless.@wifi-iface[{$uciID}].encryption", 'none');
+        uciSet("wireless.@wifi-iface[{$uciID}].key", '');
+
+        if ($radioID === false) {
+            execBackground("wifi");
+        } else {
+            execBackground("wifi reload {$radioID}");
+            execBackground("wifi up {$radioID}");
+        }
+
+        return array("success" => true);
+    }
+}

+ 96 - 0
src/pineapple/modules/Networking/api/Interfaces.php

@@ -0,0 +1,96 @@
+<?php namespace helper;
+
+class Interfaces
+{
+
+    public function getMacData()
+    {
+        $macData = array();
+        exec("ifconfig -a | grep wlan | awk '{print \$1\" \"\$5}'", $interfaceArray);
+        foreach ($interfaceArray as $interface) {
+            $interface = explode(" ", $interface);
+            $macData[$interface[0]] = $interface[1];
+        }
+        return $macData;
+    }
+
+    public function getUciID($interface)
+    {
+        $interfaceNumber = str_replace("wlan", "", $interface);
+        if ($interfaceNumber === "0") {
+            return 0;
+        } elseif ($interfaceNumber === "0-1") {
+            return 1;
+        } elseif ($interfaceNumber === "0-2") {
+            return 2;
+        } else {
+            return (intval($interfaceNumber) + 2);
+        }
+    }
+
+    public function getRadioID($interface)
+    {
+        exec('wifi status', $wifiStatus);
+        $radioArray = json_decode(implode("\n", $wifiStatus));
+
+        foreach ($radioArray as $radio => $radioConfig) {
+            if (isset($radioConfig->interfaces[0]->config->ifname)) {
+                if ($radioConfig->interfaces[0]->config->ifname === $interface) {
+                    return $radio;
+                }
+            }
+        }
+        return false;
+    }
+
+    public function setMac($random, $interface, $newMac)
+    {
+        $uciID = $this->getUciID($interface);
+        $interface = escapeshellarg($interface);
+
+        if ($random) {
+            $mac = exec("ifconfig {$interface} down && macchanger -A {$interface} | grep New | awk '{print \$3}'");
+        } else {
+            $requestMac = escapeshellarg($newMac);
+            $mac = exec("ifconfig {$interface} down && macchanger -m {$requestMac} {$interface} | grep New | awk '{print \$3}'");
+        }
+
+        uciSet("wireless.@wifi-iface[{$uciID}].macaddr", $mac);
+        exec("wifi");
+        return array("success" => true, "uci" => $uciID);
+    }
+
+    public function resetMac($interface)
+    {
+        $uciID = $this->getUciID($interface);
+        exec("uci set wireless.@wifi-iface[{$uciID}].macaddr=''");
+        exec("wifi");
+        return array("success" => true);
+    }
+
+    public function resetWirelessConfig()
+    {
+        execBackground("wifi config > /etc/config/wireless && wifi");
+        return array("success" => true);
+    }
+
+    public function getInterfaceList()
+    {
+        exec("ifconfig -a | grep encap:Ethernet | awk '{print \$1\",\"\$5}'", $interfaceArray);
+        return $interfaceArray;
+    }
+
+    public function getClientInterfaces()
+    {
+        $clientInterfaces = array();
+        exec("ifconfig -a | grep wlan | awk '{print \$1}'", $interfaceArray);
+
+        foreach ($interfaceArray as $interface) {
+            if (substr($interface, 0, 5) === "wlan0") {
+                continue;
+            }
+            array_push($clientInterfaces, $interface);
+        }
+        return array_reverse($clientInterfaces);
+    }
+}

+ 255 - 0
src/pineapple/modules/Networking/api/module.php

@@ -0,0 +1,255 @@
+<?php namespace pineapple;
+
+require_once('AccessPoint.php');
+require_once('ClientMode.php');
+require_once('Interfaces.php');
+
+class Networking extends SystemModule
+{
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'getRoutingTable':
+                $this->getRoutingTable();
+                break;
+
+            case 'restartDNS':
+                $this->restartDNS();
+                break;
+
+            case 'updateRoute':
+                $this->updateRoute();
+                break;
+
+            case 'getAdvancedData':
+                $this->getAdvancedData();
+                break;
+
+            case 'setHostname':
+                $this->setHostname();
+                break;
+
+            case 'resetWirelessConfig':
+                $this->resetWirelessConfig();
+                break;
+
+            case 'getInterfaceList':
+                $this->getInterfaceList();
+                break;
+
+            case 'saveAPConfig':
+                $this->saveAPConfig();
+                break;
+
+            case 'getAPConfig':
+                $this->getAPConfig();
+                break;
+
+            case 'getMacData':
+                $this->getMacData();
+                break;
+
+            case 'setMac':
+                $this->setMac(false);
+                break;
+
+            case 'setRandomMac':
+                $this->setMac(true);
+                break;
+
+            case 'resetMac':
+                $this->resetMac();
+                break;
+
+            case 'scanForNetworks':
+                $this->scanForNetworks();
+                break;
+
+            case 'getClientInterfaces':
+                $this->getClientInterfaces();
+                break;
+
+            case 'connectToAP':
+                $this->connectToAP();
+                break;
+
+            case 'checkConnection':
+                $this->checkConnection();
+                break;
+
+            case 'disconnect':
+                $this->disconnect();
+                break;
+
+            case 'getOUI':
+                $this->getOUI();
+                break;
+
+            case 'getFirewallConfig':
+                $this->getFirewallConfig();
+                break;
+
+            case 'setFirewallConfig':
+                $this->setFirewallConfig();
+                break;
+        }
+    }
+
+    private function getRoutingTable()
+    {
+        exec('ifconfig | grep encap:Ethernet | awk "{print \$1}"', $routeInterfaces);
+        exec('route', $routingTable);
+        $routingTable = implode("\n", $routingTable);
+        $this->response = array('routeTable' => $routingTable, 'routeInterfaces' => $routeInterfaces);
+    }
+
+    private function restartDNS()
+    {
+        $this->execBackground('/etc/init.d/dnsmasq restart');
+        $this->response = array("success" => true);
+    }
+
+    private function updateRoute()
+    {
+        $routeInterface = escapeshellarg($this->request->routeInterface);
+        $routeIP = escapeshellarg($this->request->routeIP);
+        exec("route del default");
+        exec("route add default gw {$routeIP} {$routeInterface}");
+        $this->response = array("success" => true);
+    }
+
+    private function getAdvancedData()
+    {
+        exec("ifconfig -a", $ifconfig);
+        $this->response = array("hostname" => gethostname(), "ifconfig" => implode("\n", $ifconfig));
+    }
+
+    private function setHostname()
+    {
+        exec("uci set system.@system[0].hostname=" . escapeshellarg($this->request->hostname));
+        exec("uci commit system");
+        exec("echo $(uci get system.@system[0].hostname) > /proc/sys/kernel/hostname");
+        $this->response = array("success" => true);
+    }
+
+    private function resetWirelessConfig()
+    {
+        $interfaceHelper = new \helper\Interfaces();
+        $this->response = $interfaceHelper->resetWirelessConfig();
+    }
+
+    private function getInterfaceList()
+    {
+        $interfaceHelper = new \helper\Interfaces();
+        $this->response = $interfaceHelper->getInterfaceList();
+    }
+
+    private function saveAPConfig()
+    {
+        $accessPointHelper = new \helper\AccessPoint();
+        $config = $this->request->apConfig;
+        if (empty($config->openSSID) || empty($config->managementSSID)) {
+            $this->error = "Error: SSIDs must be at least one character.";
+            return;
+        }
+        if (strlen($config->managementKey) < 8 && $config->disableManagementAP == false) {
+            $this->error = "Error: WPA2 Passwords must be at least 8 characters long.";
+            return;
+        }
+        $this->response = $accessPointHelper->saveAPConfig($config);
+    }
+
+    private function getAPConfig()
+    {
+        $accessPointHelper = new \helper\AccessPoint();
+        $this->response = $accessPointHelper->getAPConfig();
+    }
+
+    private function getMacData()
+    {
+        $interfaceHelper = new \helper\Interfaces();
+        $this->response = $interfaceHelper->getMacData();
+    }
+
+    private function setMac($random)
+    {
+        $interfaceHelper = new \helper\Interfaces();
+        $this->response = $interfaceHelper->setMac($random, $this->request->interface, $this->request->mac);
+    }
+
+    private function resetMac()
+    {
+        $interfaceHelper = new \helper\Interfaces();
+        $this->response = $interfaceHelper->resetMac($this->request->interface);
+    }
+
+    private function checkConnection()
+    {
+        $clientModeHelper = new \helper\ClientMode();
+        $this->response = $clientModeHelper->checkConnection();
+    }
+
+    private function disconnect()
+    {
+        $interfaceHelper = new \helper\Interfaces();
+        $clientModeHelper = new \helper\ClientMode();
+        $interface = $this->request->interface;
+        $uciID = $interfaceHelper->getUciID($interface);
+        $radioID = $interfaceHelper->getRadioID($interface);
+        $this->response = $clientModeHelper->disconnect($uciID, $radioID);
+    }
+
+    private function connectToAP()
+    {
+        $interfaceHelper = new \helper\Interfaces();
+        $clientModeHelper = new \helper\ClientMode();
+        $interface = $this->request->interface;
+        $uciID = $interfaceHelper->getUciID($interface);
+        $radioID = $interfaceHelper->getRadioID($interface);
+        $this->response = $clientModeHelper->connectToAP($uciID, $this->request->ap, $this->request->key, $radioID);
+    }
+
+    private function scanForNetworks()
+    {
+        $interfaceHelper = new \helper\Interfaces();
+        $clientModeHelper = new \helper\ClientMode();
+        $interface = $this->request->interface;
+        $uciID = $interfaceHelper->getUciID($interface);
+        $radioID = $interfaceHelper->getRadioID($interface);
+        $this->response = $clientModeHelper->scanForNetworks($interface, $uciID, $radioID);
+    }
+
+    private function getClientInterfaces()
+    {
+        $interfaceHelper = new \helper\Interfaces();
+        $this->response = $interfaceHelper->getClientInterfaces();
+    }
+
+    private function getOUI()
+    {
+        $data = file_get_contents("https://www.wifipineapple.com/oui.txt");
+        if ($data !== null) {
+            $this->response = array("ouiText" => implode("\n", $data));
+        } else {
+            $this->error = "Failed to download OUI file from WiFiPineapple.com";
+        }
+    }
+
+    private function getFirewallConfig()
+    {
+        $this->response = array("allowWANSSH" => $this->uciGet("firewall.allowssh.enabled"),
+                                "allowWANUI" => $this->uciGet("firewall.allowui.enabled"));
+    }
+
+    private function setFirewallConfig()
+    {
+        $wan = $this->request->WANSSHAccess ? 1 : 0;
+        $ui = $this->request->WANUIAccess ? 1 : 0;
+        $this->uciSet("firewall.allowssh.enabled", $wan);
+        $this->uciSet("firewall.allowui.enabled", $ui);
+        $this->uciSet("firewall.allowws.enabled", $ui);
+        exec('/etc/init.d/firewall restart');
+
+        $this->response = array("success" => true);
+    }
+}

+ 473 - 0
src/pineapple/modules/Networking/js/module.js

@@ -0,0 +1,473 @@
+registerController('NetworkingRouteController', ['$api', '$scope', '$timeout', function($api, $scope, $timeout) {
+    $scope.restartedDNS = false;
+    $scope.routeTable = "";
+    $scope.routeInterface = "br-lan";
+    $scope.routeInterfaces = [];
+
+
+    $scope.getRoute = (function(){
+        $api.request({
+            module: 'Networking',
+            action: 'getRoutingTable'
+        }, function(response){
+            $scope.routeTable = response.routeTable;
+            $scope.routeInterfaces = response.routeInterfaces;
+        });
+    });
+
+    $scope.restartDNS = (function() {
+        $api.request({
+            module: 'Networking',
+            action: 'restartDNS'
+        }, function(response) {
+            if (response.success === true) {
+                $scope.restartedDNS = true;
+                $timeout(function(){
+                    $scope.restartedDNS = false;
+                }, 2000);
+            }
+        });
+    });
+
+    $scope.updateRoute = (function() {
+        $api.request({
+            module: 'Networking',
+            action: 'updateRoute',
+            routeIP: $scope.routeIP,
+            routeInterface: $scope.routeInterface
+        }, function(response) {
+            if (response.success === true) {
+                $scope.getRoute();
+                $scope.updatedRoute = true;
+                $timeout(function(){
+                    $scope.updatedRoute = false;
+                }, 2000);
+            }
+        });
+    });
+
+    $scope.getRoute();
+
+}]);
+
+registerController('NetworkingAccessPointsController', ['$api', '$scope', '$timeout', function($api, $scope, $timeout) {
+    $scope.apConfigurationSaved = false;
+    $scope.apConfigurationError = "";
+    $scope.apAvailableChannels = [];
+    $scope.apConfig = {
+        availableChannels: [],
+        selectedChannel: "1",
+        openSSID: "",
+        hideOpenAP: false,
+        managementSSID: "",
+        managementKey: "",
+        disableManagementAP: false,
+        hideManagementAP: false
+    };
+
+    $scope.saveAPConfiguration = (function() {
+        $api.request({
+            module: "Networking",
+            action: "saveAPConfig",
+            apConfig: $scope.apConfig
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.apConfigurationSaved = true;
+                $timeout(function(){
+                    $scope.apConfigurationSaved = false;
+                }, 6000);
+            } else {
+                $scope.apConfigurationError = response.error;
+                $timeout(function(){
+                    $scope.apConfigurationError = "";
+                }, 3000);
+            }
+        })
+    });
+
+    $scope.getAPConfiguration = (function() {
+        $api.request({
+            module: "Networking",
+            action: "getAPConfig"
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.apConfig = response;
+                if ($scope.apConfig['selectedChannel'] === true) {
+                    $scope.apConfig['selectedChannel'] = "1";
+                }
+            }
+        })
+    });
+
+    $scope.getAPConfiguration();
+}]);
+
+registerController('NetworkingClientModeController', ['$api', '$scope', '$timeout', function($api, $scope, $timeout) {
+    $scope.interfaces = [];
+    $scope.selectedInterface = "";
+    $scope.accessPoints = [];
+    $scope.selectedAP = {};
+    $scope.scanning = false;
+    $scope.key = "";
+    $scope.connected = true;
+    $scope.connecting = false;
+    $scope.noNetworkFound = false;
+
+    $scope.getInterfaces = (function() {
+        $api.request({
+            module: 'Networking',
+            action: 'getClientInterfaces'
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.interfaces = response;
+                $scope.selectedInterface = $scope.interfaces[0];
+            }
+        });
+    });
+
+    $scope.scanForNetworks = (function() {
+        $scope.scanning = true;
+        $api.request({
+            module: 'Networking',
+            action: 'scanForNetworks',
+            interface: $scope.selectedInterface
+        }, function(response) {
+            if (response.error !== undefined) {
+                $scope.noNetworkFound = true;
+            } else {
+                $scope.noNetworkFound = false;
+                $scope.accessPoints = response;
+                $scope.selectedAP = $scope.accessPoints[0];
+            }
+            $scope.scanning = false;
+        });
+    });
+
+    $scope.connectToAP = (function() {
+        $scope.connecting = true;
+        $api.request({
+            module: 'Networking',
+            action: 'connectToAP',
+            interface: $scope.selectedInterface,
+            ap: $scope.selectedAP,
+            key: $scope.key
+        }, function() {
+            $scope.key = "";
+            $timeout(function() {
+                $scope.checkConnection();
+                $scope.connecting = false;
+            }, 10000);
+        });
+    });
+
+    $scope.checkConnection = (function() {
+        $api.request({
+            module: 'Networking',
+            action: 'checkConnection'
+        }, function(response) {
+            if (response.error === undefined) {
+                if (response.connected) {
+                    $scope.connected = true;
+                    $scope.connectedInterface = response.interface;
+                    $scope.connectedSSID = response.ssid;
+                    $scope.connectedIP = response.ip;
+                } else {
+                    $scope.connected = false;
+                    $scope.getInterfaces();
+                }
+            }
+        });
+    });
+
+    $scope.disconnect = (function() {
+        $scope.disconnecting = true;
+        $api.request({
+            module: 'Networking',
+            action: 'disconnect',
+            interface: $scope.connectedInterface
+        }, function(response) {
+            if (response.error === undefined) {
+                $timeout(function() {
+                    $scope.getInterfaces();
+                    $scope.connected = false;
+                    $scope.disconnecting = false;
+                    $scope.accessPoints = [];
+                }, 10000);
+            }
+        });
+    });
+
+    $scope.checkConnection();
+}]);
+
+registerController('NetworkingFirewallController', ['$api', '$scope', '$timeout', function($api, $scope, $timeout) {
+    $scope.firewallUpdated = false;
+    $scope.WANSSHAccess = false;
+    $scope.WANUIAccess = false;
+    $scope.device = '';
+
+    $scope.getDevice = (function() {
+        $api.request({
+            module: 'Configuration',
+            action: 'getDevice'
+        }, function(response) {
+            $scope.device = response.device;
+        });
+    });
+    $scope.getDevice();
+
+    $scope.getFirewallConfig = (function() {
+        $api.request({
+            module: 'Networking',
+            action: 'getFirewallConfig'
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.WANSSHAccess = response.allowWANSSH;
+                $scope.WANUIAccess = response.allowWANUI;
+            }
+        });
+    });
+
+    $scope.setFirewallConfig = (function() {
+        $api.request({
+            module: 'Networking',
+            action: 'setFirewallConfig',
+            WANSSHAccess: $scope.WANSSHAccess,
+            WANUIAccess: $scope.WANUIAccess
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.firewallUpdated = true;
+                $timeout(function(){
+                    $scope.firewallUpdated = false;
+                }, 2000);
+            }
+        })
+    });
+
+    $scope.getFirewallConfig();
+}]);
+
+registerController('NetworkingMACAddressesController', ['$api', '$scope', '$timeout', function($api, $scope, $timeout) {
+    $scope.interfaces = [];
+    $scope.selectedInterface = "wlan0";
+    $scope.newMac = "";
+    $scope.modifyingMAC = false;
+
+    $scope.getMacData = (function() {
+        $api.request({
+            module: 'Networking',
+            action: 'getMacData'
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.interfaces = response;
+            }
+        });
+    });
+
+    $scope.setMac = (function() {
+        $scope.modifyingMAC = true;
+        $api.request({
+            module: 'Networking',
+            action: 'setMac',
+            interface: $scope.selectedInterface,
+            mac: $scope.newMac
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.newMac = "";
+                $timeout(function(){
+                    $scope.modifyingMAC = false;
+                    $scope.getMacData();
+                }, 6000);
+            }
+        });
+    });
+
+    $scope.setRandomMac = (function() {
+        $scope.modifyingMAC = true;
+        $api.request({
+            module: 'Networking',
+            action: 'setRandomMac',
+            interface: $scope.selectedInterface
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.newMac = "";
+                $timeout(function(){
+                    $scope.modifyingMAC = false;
+                    $scope.getMacData();
+                }, 6000);
+            }
+        });
+    });
+
+    $scope.resetMac = (function() {
+        $scope.modifyingMAC = true;
+        $api.request({
+            module: 'Networking',
+            action: 'resetMac',
+            interface: $scope.selectedInterface
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.newMac = "";
+                $timeout(function(){
+                    $scope.modifyingMAC = false;
+                    $scope.getMacData();
+                }, 6000);
+            }
+        });
+    });
+
+    $scope.getMacData();
+}]);
+
+registerController('NetworkingAdvancedController', ['$api', '$scope', '$timeout', function($api, $scope, $timeout) {
+    $scope.hostnameUpdated = false;
+    $scope.wirelessReset = false;
+    $scope.data = {
+        hostname: "Pineapple",
+        ifconfig: ""
+    };
+
+    $scope.reloadData = (function() {
+        $api.request({
+            module: 'Networking',
+            action: 'getAdvancedData'
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.data = response;
+            }
+        });
+    });
+
+    $scope.setHostname = (function() {
+        $api.request({
+            module: "Networking",
+            action: "setHostname",
+            hostname: $scope.data['hostname']
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.hostnameUpdated = true;
+                $timeout(function(){
+                    $scope.hostnameUpdated = false;
+                }, 2000);
+            }
+        });
+    });
+
+    $scope.resetWirelessConfig = (function() {
+        $api.request({
+            module: 'Networking',
+            action: 'resetWirelessConfig'
+        }, function(response) {
+            if (response.error === undefined) {
+                $scope.wirelessReset = true;
+                $timeout(function(){
+                    $scope.wirelessReset = false;
+                }, 5000);
+            }
+        });
+    });
+
+    $scope.reloadData();
+}]);
+
+registerController("OUILookupController", ['$api', '$scope', '$timeout', '$http', function($api, $scope, $timeout, $http) {
+    $scope.macAddress = "";
+    $scope.vendor = "";
+    $scope.OUIDBPresent = false;
+
+    $scope.isOUIPresent = function () {
+        return localStorage.getItem("ouiText") !== null;
+    };
+
+    $scope.downloadOUIDatabase = function () {
+        if (typeof(Storage) === "undefined") {
+            return false;
+        }
+        var ouiText = localStorage.getItem("ouiText");
+        if (ouiText === null) {
+            $scope.gettingOUI = true;
+            $http.get('https://www.wifipineapple.com/oui.txt').then(
+                function (response) {
+                    localStorage.setItem("ouiText", response.data);
+                    $scope.populateDB();
+                },
+                function () {
+                    $api.request({
+                        module: "Networking",
+                        action: "getOUI"
+                    }, function (response) {
+                        if (response.error === undefined) {
+                            localStorage.setItem("ouiText", response.ouiText);
+                            $scope.populateDB();
+                        } else {
+                            return false;
+                        }
+                    });
+                });
+        }
+        return true;
+    };
+
+    $scope.populateDB = function () {
+        $scope.ouiLoading = true;
+        var request = window.indexedDB.open("pineapple", 1);
+
+        request.onupgradeneeded = function (event) {
+            var db = event.target.result;
+            var objectStore = db.createObjectStore("oui", {keyPath: "macPrefix"});
+            var text = localStorage.getItem("ouiText");
+            var pos = 0;
+            do {
+                var line = text.substring(pos, text.indexOf("\n", pos + 1)).replace('\n', '');
+                var arr = [line.substring(0, 6), line.substring(6)];
+                objectStore.add({
+                    macPrefix: arr[0],
+                    name: arr[1]
+                });
+                pos += line.length + 1;
+            } while (text.indexOf("\n", pos + 1) !== -1);
+        };
+        $scope.ouiLoading = false;
+    };
+
+    $scope.lookupMACAddress = function() {
+        $scope.ouiLoading = true;
+        if (!$scope.isOUIPresent()) {
+            return;
+        }
+        var request = window.indexedDB.open("pineapple", 1);
+        request.onsuccess = function() {
+            var db = request.result;
+            var mac = convertMACAddress($scope.macAddress);
+            var prefix = mac.substring(0, 8).replace(/:/g, '');
+            var transaction = db.transaction("oui");
+            var objectStore = transaction.objectStore("oui");
+            var lookupReq = objectStore.get(prefix);
+            lookupReq.onerror = function () {
+                window.indexedDB.deleteDatabase("pineapple");
+                $scope.vendor = "Error retrieving OUI";
+            };
+            lookupReq.onsuccess = function () {
+                if (lookupReq.result) {
+                    $scope.vendor = lookupReq.result.name;
+                } else {
+                    $scope.vendor = "Unknown MAC prefix";
+                }
+            };
+            $scope.ouiLoading = false;
+        }
+    };
+
+    $scope.removeOUIDatabase = function() {
+        localStorage.removeItem('ouiText');
+        window.indexedDB.deleteDatabase('pineapple').onsuccess = function() {
+            $scope.success = true;
+            $scope.ouiLoading = false;
+            $scope.gettingOUI = false;
+            $timeout(function() {
+                $scope.success = false;
+            }, 2000);
+        };
+    };
+
+}]);

+ 375 - 0
src/pineapple/modules/Networking/module.html

@@ -0,0 +1,375 @@
+<div class="row">
+    <div class="col-md-12" ng-controller="NetworkingRouteController">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">Route
+                    <span class="dropdown">
+                        <button class="btn btn-xs btn-default dropdown-toggle" type="button" id="NetworkingRouteDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <span class="caret"></span>
+                        </button>
+                        <ul class="dropdown-menu" aria-labelledby="NetworkingRouteDropdown">
+                            <li ng-click="restartDNS()"><a>Restart DNS</a></li>
+                        </ul>
+                    </span>
+                </h3>
+            </div>
+            <div class="panel-body">
+                <p class="alert well-sm alert-success" ng-show="restartedDNS">DNS restarted successfully</p>
+                <pre class="scrollable-pre">{{ routeTable }}</pre>
+
+                <div class="col-md-8">
+                    <div class="row">
+                        <div class="input-group">
+                            <span class="input-group-addon">Default Route</span>
+                            <input type="text" class="form-control" id="routeIP" name="routeIP" placeholder="172.16.42.42" ng-model="routeIP">
+                            <span class="input-group-addon">Interface</span>
+                            <select class="form-control inline-form" id="routeInterface" name="routeInterface" ng-model="routeInterface">
+                                <option ng-repeat="interface in routeInterfaces">{{ interface }}</option>
+                            </select>
+                        </div>
+                        <div class="col-md-12 input-group" ng-show="updatedRoute">
+                            <br/>
+                            <p class="alert well-sm alert-success">The default route has been updated successfully</p>
+                        </div>
+                    </div>
+                </div>
+                <div class="col-md-1"></div>
+                <div class="col-md-3">
+                    <div class="row">
+                        <div class="input-group">
+                            <button class="btn btn-default" ng-click="updateRoute()">Update Route</button>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="row">
+    <div class="col-md-6" ng-controller="NetworkingAccessPointsController">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">Access Points</h3>
+            </div>
+            <div class="panel-body">
+                <div class="col-md-12">
+                    <div class="row">
+                        <div class="input-group">
+                            <span class="input-group-addon">AP Channel</span>
+                            <select class="form-control" ng-model="apConfig['selectedChannel']">
+                                <option ng-repeat="channel in apConfig['availableChannels']">{{ channel }}</option>
+                            </select>
+                        </div>
+                        <br/>
+                        <div class="input-group">
+                            <span class="fixed-addon-width-3 input-group-addon">Management SSID</span>
+                            <input type="text" class="form-control" ng-model="apConfig['managementSSID']">
+                        </div>
+                        <br/>
+                        <div class="input-group">
+                            <span class="fixed-addon-width-3 input-group-addon">Management PSK</span>
+                            <input type="password" class="form-control" ng-model="apConfig['managementKey']">
+                        </div>
+                        <br/>
+                        <div class="input-group">
+                            <div class="col-md-5">
+                               <div class="checkbox">
+                                    <label for="hideManagementAP"><input id="hideManagementAP" type="checkbox" value="" ng-model="apConfig['hideManagementAP']">Hide Management AP</label>
+                                </div>
+                            </div>
+                            <div class="col-md-5">
+                                <div class="checkbox">
+                                    <label for="disableManagementAP"><input id="disableManagementAP" type="checkbox" value="" ng-model="apConfig['disableManagementAP']">Disable Management AP</label>
+                                </div>
+                            </div>
+                        </div>
+                        <br/>
+                    </div>
+                    <div class="row">
+                        <div class="input-group">
+                            <span class="input-group-addon">Open SSID</span>
+                            <input type="text" class="form-control" ng-model="apConfig['openSSID']">
+                        </div>
+                        <br/>
+                        <div class="input-group">
+                            <span class="input-group-addon">Maximum Clients</span>
+                            <input type="text" class="form-control" ng-model="apConfig['maxClients']">
+                        </div>
+                        <br/>
+                        <div class="input-group">
+                            <div class="col-md-12">
+                               <div class="checkbox">
+                                    <label for="hideOpenAP"><input id="hideOpenAP" type="checkbox" value="" ng-model="apConfig['hideOpenAP']">Hide Open SSID</label>
+                                </div>
+                            </div>
+                        </div>
+                        <br/>
+                        <div class="input-group">
+                            <button class="btn btn-default" ng-click="saveAPConfiguration()">Update Access Points</button>
+                        </div>
+                        <br/>
+                        <div class="alert well-sm alert-success" ng-show="apConfigurationSaved">The Wireless AP Configuration has been saved. The radios will now restart. If connected over Wireless, you may need to reconnect.</div>
+                        <div class="alert well-sm alert-danger" ng-show="apConfigurationError">{{ apConfigurationError }}</div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="col-md-6">
+        <div ng-controller="NetworkingFirewallController" ng-show="device == 'tetra'">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <h3 class="panel-title">Firewall</h3>
+                </div>
+                <div class="panel-body">
+                    <div class="row">
+                        <div class="col-md-12">
+                            <div class="input-group">
+                                <div class="checkbox">
+                                    <label><input type="checkbox" ng-model="WANSSHAccess">Allow SSH Access via WAN</label><br/>
+                                    <label><input type="checkbox" ng-model="WANUIAccess">Allow Web UI Access via WAN</label>
+                                </div>
+                                <br/>
+                                <button class="btn btn-default" ng-click="setFirewallConfig();">Save</button>
+                            </div>
+                            <br/>
+                            <div class="alert well-sm alert-success" ng-show="firewallUpdated">The Firewall Configuration has been updated.</div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div ng-controller="OUILookupController">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <h3 class="panel-title">OUI Lookup
+                        <span class="dropdown">
+                        <button class="btn btn-xs btn-default dropdown-toggle" type="button" id="OUIDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <span class="caret"></span>
+                        </button>
+                        <ul class="dropdown-menu" aria-labelledby="OUIDropdown">
+                            <li ng-click="removeOUIDatabase()"><a>Remove OUI Database</a></li>
+                        </ul>
+                    </span>
+                    </h3>
+                </div>
+                <div class="panel-body">
+                    <div ng-if="isOUIPresent()">
+                        <form class="form-inline" role="form" novalidate>
+                            <div class="form-group">
+                                <div class="input-group">
+                                    <span class="input-group-addon">MAC Address</span>
+                                    <input name="macAddress" placeholder="00:11:22:33:44:55" type="text" class="form-control" id="macAddress" ng-model="$parent.macAddress">
+                                </div>
+                                <br/>
+                            </div>
+                            <div class="btn-group" role="group">
+                                <button ng-click="lookupMACAddress();" ng-disabled="ouiLoading" class="btn btn-default"><span ng-hide="ouiLoading">Lookup</span><img class="center-block" ng-show="ouiLoading" src="img/throbber.gif" /></button>
+                            </div>
+                            <br/>
+                        </form>
+                        <br/>
+                        <span ng-show="vendor">{{ vendor }}</span>
+                    </div>
+                    <div ng-if="!isOUIPresent()">
+                        <button ng-click="downloadOUIDatabase()" ng-disabled="gettingOUI" class="btn btn-default"><span ng-hide="gettingOUI">Download OUI Database</span><img class="center-block" ng-show="gettingOUI" src="img/throbber.gif" /></button>
+                        <br/>
+                        <span class="text-muted">Note: The OUI Database is downloaded from WiFiPineapple.com</span>
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div ng-controller="NetworkingClientModeController">
+            <div class="panel panel-default" >
+                <div class="panel-heading">
+                    <h3 class="panel-title">WiFi Client Mode</h3>
+                </div>
+                <div class="panel-body">
+                    <div class="col-md-12" ng-show="connected">
+                        <div class="row">
+                            <div class="input-group">
+                                <span class="input-group-addon">Interface</span>
+                                <input type="text" class="form-control" ng-model="connectedInterface" disabled>
+                            </div>
+                            <br/>
+                            <div class="input-group">
+                                <span class="fixed-addon-width input-group-addon">SSID</span>
+                                <input type="text" class="form-control" ng-model="connectedSSID" disabled>
+                            </div>
+                            <br/>
+                            <div class="input-group">
+                                <span class="fixed-addon-width input-group-addon">IP</span>
+                                <input type="text" class="form-control" ng-model="connectedIP" disabled>
+                            </div>
+                            <br/>
+                            <button class="btn btn-default" ng-click="checkConnection()" ng-hide="disconnecting">Refresh</button>
+                            <button class="btn btn-default" ng-click="disconnect()" ng-hide="disconnecting">Disconnect</button>
+                            <img src="img/throbber.gif" ng-show="disconnecting">
+                        </div>
+                    </div>
+                    <div class="col-md-12" ng-hide="connected">
+                        <div class="row">
+                            <div class="input-group">
+                                <span class="input-group-addon">Interface</span>
+                                <select class="form-control" ng-model="selectedInterface" ng-disabled="scanning">
+                                    <option ng-repeat="interface in interfaces">{{ interface }}</option>
+                                </select>
+                                <span class="input-group-btn">
+                                    <button ng-disabled="scanning" class="btn btn-default" type="button" ng-click="scanForNetworks()">
+                                        <span ng-hide="scanning">Scan</span>
+                                        <img class="image-small-18" src="img/throbber.gif" ng-show="scanning">
+                                    </button>
+                                </span>
+                            </div>
+                            <small class="text-muted" ng-hide="accessPoints.length !== 0" ng-show="selectedInterface === 'wlan1' || selectedInterface === 'wlan1mon'"> Note: Choosing wlan1 will interfere with PineAP.</small>
+                            <div class="alert well-sm alert-warning" role="alert" ng-show="noNetworkFound">
+                                <span>No networks were found.</span>
+                            </div>
+                            <br/>
+                        </div>
+                        <div class="row" ng-show="accessPoints.length">
+                            <div class="input-group">
+                                <span class="input-group-addon">Access Point</span>
+                                <select class="form-control" ng-options="ap.ssid for ap in accessPoints track by ap.mac" ng-model="selectedAP"></select>
+                            </div>
+                            <br/>
+                            <div class="input-group" ng-show="(selectedAP['security'] !== 'Open')">
+                                <span class="input-group-addon">Password</span>
+                                <input type="password" class="form-control" ng-model="key">
+                            </div>
+                            <br/>
+                        </div>
+                        <br/><br/>
+                        <div class="row" ng-show="accessPoints.length">
+                            <div class="input-group">
+                                <span class="fixed-addon-width-2 input-group-addon">BSSID</span>
+                                <input type="text" class="form-control" ng-model="selectedAP['mac']" disabled>
+                            </div>
+                            <br/>
+                            <div class="input-group">
+                                <span class="fixed-addon-width-2 input-group-addon">SSID</span>
+                                <input type="text" class="form-control" ng-model="selectedAP['ssid']" disabled>
+                            </div>
+                            <br/>
+                            <div class="input-group">
+                                <span class="fixed-addon-width-2 input-group-addon">Channel</span>
+                                <input type="text" class="form-control" ng-model="selectedAP['channel']" disabled>
+                            </div>
+                            <br/>
+                            <div class="input-group">
+                                <span class="fixed-addon-width-2 input-group-addon">Signal</span>
+                                <input type="text" class="form-control" ng-model="selectedAP['signal']" disabled>
+                            </div>
+                            <br/>
+                            <div class="input-group">
+                                <span class="fixed-addon-width-2 input-group-addon">Quality</span>
+                                <input type="text" class="form-control" ng-model="selectedAP['quality']" disabled>
+                            </div>
+                            <br/>
+                            <div class="input-group">
+                                <span class="fixed-addon-width-2 input-group-addon">Security</span>
+                                <input type="text" class="form-control" ng-model="selectedAP['security']" disabled>
+                            </div>
+                            <br/>
+                        </div>
+                        <div class="row">
+                            <button class="btn btn-default" ng-click="connectToAP()" ng-show="accessPoints.length" ng-disabled="connecting">
+                                <span ng-hide="connecting">Connect</span>
+                                <img class="image-small-18" src="img/throbber.gif" ng-show="connecting">
+                            </button>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="row">
+    <div class="col-md-6" ng-controller="NetworkingMACAddressesController">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">MAC Addresses
+                    <span class="dropdown">
+                        <button ng-disabled="modifyingMAC" class="btn btn-xs btn-default dropdown-toggle" type="button" id="NetworkingAccessPointsDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <span class="caret"></span>
+                        </button>
+                        <ul class="dropdown-menu" aria-labelledby="NetworkingAccessPointsDropdown">
+                            <li ng-disabled="waiting" ng-click="resetMac()"><a>Restore Default MAC Address</a></li>
+                        </ul>
+                    </span>
+                </h3>
+            </div>
+            <div class="panel-body">
+                <div class="col-md-12">
+                    <div class="row">
+                        <div class="input-group">
+                            <span class="input-group-addon">Interface</span>
+                            <select ng-disabled="modifyingMAC" class="form-control" ng-model="selectedInterface">
+                                <option ng-repeat="(interface, mac) in interfaces">{{ interface }}</option>
+                            </select>
+                        </div>
+                        <br/>
+                        <div class="input-group">
+                            <span class="fixed-addon-width-3 input-group-addon">Current MAC</span>
+                            <input type="text" class="form-control autoselect" ng-model="interfaces[selectedInterface]" disabled>
+                        </div>
+                        <br/>
+                        <div class="input-group">
+                            <span class="fixed-addon-width-3 input-group-addon">New MAC</span>
+                            <input ng-disabled="modifyingMAC" type="text" class="form-control" ng-model="newMac">
+                        </div>
+                        <br/><br/>
+                        <button ng-disabled="modifyingMAC" class="btn btn-default" ng-click="setMac()">Set New MAC</button>
+                        <button ng-disabled="modifyingMAC" class="btn btn-default" ng-click="setRandomMac()">Set Random MAC</button>
+                        <img ng-show="modifyingMAC" src="img/throbber.gif"/>
+                        <br/>
+                        <small class="text-muted">Note: Changing MAC addresses will restart the WiFi.</small>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="col-md-6" ng-controller="NetworkingAdvancedController">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">Advanced
+                    <span class="dropdown">
+                        <button class="btn btn-xs btn-default dropdown-toggle" type="button" id="NetworkingAdvancedDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                            <small><span class="caret"></span></small>
+                        </button>
+                        <ul class="dropdown-menu" aria-labelledby="NetworkingAdvancedDropdown">
+                            <li ng-click="resetWirelessConfig()"><a>Reset WiFi Config to Defaults</a></li>
+                        </ul>
+                    </span>
+                </h3>
+            </div>
+            <div class="panel-body">
+                <div class="alert well-sm alert-success" ng-show="wirelessReset">The Wireless Configuration has been reset. The Network will now restart.</div>
+                <div class="alert well-sm alert-success" ng-show="hostnameUpdated">The hostname has been updated successfully.</div>
+                <div class="col-md-12">
+                    <div class="row">
+                        <div class="input-group">
+                            <span class="input-group-addon">Hostname</span>
+                            <input type="text" class="form-control" id="hostname" ng-model="data['hostname']">
+                            <div class="input-group-btn">
+                                <button class="btn btn-default" ng-click="setHostname()">Update</button>
+                            </div>
+                        </div>
+                    </div>
+                    <br/>
+                    <div class="row">
+                        <pre class="scrollable-pre">{{ data['ifconfig'] }}</pre>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="row">
+
+</div>

+ 12 - 0
src/pineapple/modules/Networking/module.info

@@ -0,0 +1,12 @@
+{
+    "author": "Hak5",
+    "description": "Make changes to Routing, Access Points, MAC Addresses and the Hostname.",
+    "devices": [
+        "nano",
+        "tetra"
+    ],
+    "system": true,
+    "title": "Networking",
+    "version": "1.1",
+    "index": 10
+}

+ 16 - 0
src/pineapple/modules/Networking/module_icon.svg

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 256 256" style="enable-background:new 0 0 256 256;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#808080;}
+</style>
+<path id="XMLID_23_" class="st0" d="M253.2,192c-1.7-7-5.1-12.1-10.3-15.3c-1.4-0.9-2.9-1.5-4.3-2c4.9-10.5,6.7-25.4-6-39.3
+	c-9.6-10.6-23.3-11-33.4-9.4c4-16.4,2-39.1-8.6-56.6c-9.3-15.3-28.6-32.9-68.1-29.7c-20.6,1.6-36.2,9.5-46.4,23.5
+	c-14.6,20-14,47.2-12.3,61.9c-18-3.3-43.7,0-55.2,29.1c-7.3,18.4-5.9,39.2,3.5,53c6,8.8,14.6,13.7,24.2,13.7h196.3
+	c6.8,0,12.7-2.7,16.6-7.6C253.6,207.6,255.1,199.9,253.2,192z M120,136.7l-8.1-8.1v56.1c0,3.6-2.9,6.5-6.5,6.5
+	c-3.6,0-6.5-2.9-6.5-6.5v-56.1l-8.2,8.2c-2.5,2.5-6.6,2.5-9.1,0c-2.5-2.5-2.5-6.6,0-9.1l19.2-19.2c1.2-1.2,2.9-1.9,4.6-1.9
+	c1.7,0,3.4,0.7,4.6,1.9l19.2,19.2c2.5,2.5,2.5,6.6,0,9.1c-1.3,1.3-2.9,1.9-4.6,1.9S121.3,137.9,120,136.7z M176.3,170l-19.2,19.2
+	c-1.2,1.2-2.9,1.9-4.6,1.9c-1.7,0-3.4-0.7-4.6-1.9L128.9,170c-2.5-2.5-2.5-6.6,0-9.1c2.5-2.5,6.6-2.5,9.1,0l8.1,8.1v-56.1
+	c0-3.6,2.9-6.5,6.5-6.5c3.6,0,6.5,2.9,6.5,6.5V169l8.2-8.2c2.5-2.5,6.6-2.5,9.1,0C178.9,163.4,178.9,167.5,176.3,170z"/>
+</svg>

+ 110 - 0
src/pineapple/modules/Notes/api/module.php

@@ -0,0 +1,110 @@
+<?php namespace pineapple;
+
+class Notes extends SystemModule
+{
+
+    private $dbConnection;
+    const DATABASE = "/etc/pineapple/pineapple.db";
+
+    public function __construct($request)
+    {
+        parent::__construct($request, __CLASS__);
+        $this->dbConnection = new DatabaseConnection(self::DATABASE);
+        if (!empty($this->dbConnection->error)) {
+            $this->error = $this->dbConnection->strError();
+            return;
+        }
+        $this->dbConnection->exec("CREATE TABLE IF NOT EXISTS notes (type INT, key TEXT UNIQUE NOT NULL, name TEXT, note TEXT);");
+        if (!empty($this->dbConnection->error)) {
+            $this->error = $this->dbConnection->strError();
+        }
+    }
+
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'setName':
+                $this->response = $this->setName($this->request->type, $this->request->key, $this->request->name);
+                break;
+            case 'setNote':
+                $this->response = $this->setNote($this->request->type, $this->request->key, $this->request->name, $this->request->note);
+                break;
+            case 'getNotes':
+                $this->response = $this->getNotes();
+                break;
+            case 'getNote':
+                $this->response = $this->getNote($this->request->key);
+                break;
+            case 'deleteNote':
+                $this->response = $this->deleteNote($this->request->key);
+                break;
+            case 'downloadNotes':
+                $this->response = $this->downloadNotes();
+                break;
+            case 'getKeys':
+                $this->response = $this->getKeys();
+                break;
+        }
+    }
+
+    public function setName($type, $key, $name)
+    {
+        return $this->dbConnection->exec("INSERT OR REPLACE INTO notes (type, key, name) VALUES('%d', '%s', '%s');", $type, $key, $name);
+    }
+
+    public function setNote($type, $key, $name, $note)
+    {
+        if (empty($name) && empty($note)) {
+            return $this->deleteNote($key);
+        } else {
+            return $this->dbConnection->exec("INSERT OR REPLACE INTO notes (type, key, name, note) VALUES ('%d', '%s', '%s', '%s');", $type, $key, $name, $note);
+        }
+    }
+
+    public function getNotes()
+    {
+        $macs = $this->dbConnection->query("SELECT type, key, name, note FROM notes WHERE type=0;");
+        $ssids = $this->dbConnection->query("SELECT type, key, name, note FROM notes WHERE type=1;");
+        return array("macs" => $macs, "ssids" => $ssids);
+    }
+
+    public function getNote($key)
+    {
+        return array("note" => $this->dbConnection->query("SELECT type, key, name, note FROM notes WHERE key='%s';", $key));
+    }
+
+    public function deleteNote($key)
+    {
+        if (!isset($key)) {
+            return array("success" => false);
+        }
+        $this->dbConnection->exec("DELETE FROM notes WHERE key='%s';", $key);
+        return array("success" => true);
+    }
+
+    public function downloadNotes()
+    {
+        $noteData = $this->dbConnection->query('SELECT * FROM notes;');
+        foreach ($noteData as $idx => $note) {
+            if ($note['type'] == 0) {
+                $note['type'] = 'MAC';
+            } else if ($note['type'] == 1) {
+                $note['type'] = 'SSID';
+            }
+            $noteData[$idx] = $note;
+        }
+        $fileName = '/tmp/notes.json';
+        file_put_contents($fileName, json_encode($noteData, JSON_PRETTY_PRINT));
+        return array("download" => $this->downloadFile($fileName));
+    }
+
+    public function getKeys()
+    {
+        $keys = array();
+        $res = $this->dbConnection->query("SELECT key FROM notes;");
+        foreach ($res as $idx => $key) {
+            array_push($keys, $key['key']);
+        }
+        return array("keys" => $keys);
+    }
+}

+ 44 - 0
src/pineapple/modules/Notes/js/module.js

@@ -0,0 +1,44 @@
+registerController("NotesController", ['$api', '$scope', function($api, $scope){
+    $scope.macs = [];
+    $scope.ssids = [];
+    $scope.error = null;
+
+    $scope.getNotes = function() {
+    	$api.request({
+    		module: "Notes",
+    		action: "getNotes"
+    	}, function(response) {
+    		if (response.error !== undefined) {
+    			$scope.error = response.error;
+    		} else {
+    			$scope.macs = response.macs;
+                $scope.ssids = response.ssids;
+    		}
+    	});
+    };
+
+    $scope.deleteNote = function($event) {
+        var key = $event.target.getAttribute('key');
+        $api.request({
+            module: "Notes",
+            action: "deleteNote",
+            key: key
+        }, function() {
+            $scope.getNotes();
+        });
+    };
+
+    $scope.downloadNotes = function() {
+        $scope.getNotes();
+        $api.request({
+            module: "Notes",
+            action: "downloadNotes"
+        }, function(response) {
+            if (response.error === undefined) {
+                window.location = '/api?download=' + response.download;
+            }
+        });
+    };
+
+    $scope.getNotes();
+}]);

+ 114 - 0
src/pineapple/modules/Notes/module.html

@@ -0,0 +1,114 @@
+<style type="text/css">
+.table td.text {
+    max-width: 177px;
+}
+.table td.text span {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    display: inline-block;
+    max-width: 100%;
+}
+</style>
+<div class="row" ng-controller="NotesController">
+    <div class="col-md-12">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">
+                    MAC Notes
+                    <div class="btn-group pull-right">
+                        <button class="btn btn-default btn-xs btn-fixed-length" ng-click="downloadNotes()">Download</button>
+                        <button class="btn btn-default btn-xs btn-fixed-length" ng-click="getNotes()">Refresh</button>
+                    </div>
+                </h3>
+            </div>
+            <div class="table-responsive table-dropdown" ng-show="macs.length">
+                <table class="table">
+                    <thead>
+                        <tr>
+                            <th>Name</th>
+                            <th>MAC Address</th>
+                            <th>Note</th>
+                            <th>Delete</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr ng-repeat="mac in macs">
+                            <td>
+                                <span class="autoselect">
+                                    {{ mac.name }}
+                                </span>
+                            </td>
+                            <td>
+                                <span class="autoselect">
+                                    <hook-button disable="running && !paused" hook="mac" content="mac.key" probes="true" client="true"></hook-button>
+                                    {{ mac.key }}
+                                </span>
+                            </td>
+                            <td class="text autoselect">
+                                <span class="autoselect">
+                                    <i>{{ mac.note }}</i>
+                                </span>
+                            </td>
+                            <td>
+                                <button class="btn btn-default btn-xs" key="{{mac.key}}" ng-click="deleteNote($event)">Delete Note</button>
+                            </td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="panel-body" ng-hide="macs.length">
+                No MAC notes found.
+            </div>
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <h3 class="panel-title">
+                    SSID Notes
+                    <div class="btn-group pull-right">
+                        <button class="btn btn-default btn-xs btn-fixed-length" ng-click="downloadNotes()">Download</button>
+                        <button class="btn btn-default btn-xs btn-fixed-length" ng-click="getNotes()">Refresh</button>
+                    </div>
+                </h3>
+            </div>
+            <div class="table-responsive table-dropdown" ng-show="ssids.length">
+                <table class="table">
+                    <thead>
+                        <tr>
+                            <th>Name</th>
+                            <th>SSID</th>
+                            <th>Note</th>
+                            <th>Delete</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr ng-repeat="ssid in ssids">
+                            <td>
+                                <span class="autoselect">
+                                    {{ ssid.name }}
+                                </span>
+                            </td>
+                            <td>
+                                <span class="autoselect">
+                                    <hook-button disable="running && !paused" hook="ssid" content="ssid.key" probes="true" client="false"></hook-button>
+                                    {{ ssid.key }}
+                                </span>
+                            </td>
+                            <td class="text autoselect">
+                                <span class="autoselect">
+                                    <i>{{ ssid.note }}</i>
+                                </span>
+                            </td>
+                            <td>
+                                <button class="btn btn-default btn-xs" key="{{ssid.key}}" ng-click="deleteNote($event)">Delete Note</button>
+                            </td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="panel-body" ng-hide="ssids.length">
+                No SSID notes found.
+            </div>
+        </div>
+    </div>
+</div>

+ 12 - 0
src/pineapple/modules/Notes/module.info

@@ -0,0 +1,12 @@
+{
+    "author": "Hak5",
+    "description": "Easily take notes on different clients and APs.",
+    "devices": [
+        "nano",
+        "tetra"
+    ],
+    "system": true,
+    "title": "Notes",
+    "version": "1.0",
+    "index": 13
+}

+ 14 - 0
src/pineapple/modules/Notes/module_icon.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 256 256" style="enable-background:new 0 0 256 256;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#808080;}
+</style>
+<g>
+	<path class="st0" d="M216,3H78.1c0,0-1.2,0-4.5,0c-3.9,0-8.9,1.9-13.4,6.4c-6.3,6.3-25.7,26.7-31.8,33.2c-3.3,3.5-6,8.8-6,13.8
+		c0,1.5,0,4.5,0,4.5v174.6c0,9.7,7.9,17.6,17.6,17.6H216c9.7,0,17.6-7.9,17.6-17.6V20.6C233.6,10.9,225.8,3,216,3z M66.3,16.6v26.6
+		c0,3.2-2.6,5.7-5.7,5.7H35.2L66.3,16.6z M221.8,235.4c0,3.2-2.6,5.7-5.7,5.7H40c-3.2,0-5.7-2.6-5.7-5.7V60.8h26.3
+		c9.7,0,17.6-7.9,17.6-17.6V14.9H216c3.2,0,5.7,2.6,5.7,5.7V235.4z"/>
+</g>
+</svg>

+ 269 - 0
src/pineapple/modules/PineAP/api/PineAPHelper.php

@@ -0,0 +1,269 @@
+<?php namespace pineapple;
+
+class PineAPHelper
+{
+    public function getSetting($settingKey)
+    {
+        $configFile = file_get_contents("/tmp/pineap.conf");
+
+        $configFile = explode("\n", $configFile);
+        foreach($configFile as $row => $data) {
+            $entry = str_replace(" ", "", $data);
+            $entry = explode("=", $entry);
+
+            if ($entry[0] == $settingKey) {
+                if ($entry[1] == 'on') {
+                    return true;
+                } elseif ($entry[1] == 'off') {
+                    return false;
+                } else {
+                    return $entry[1];
+                }
+            }
+        }
+
+        return false;
+    }
+
+    public function setSetting($settingKey, $settingVal)
+    {
+        $configFile = file_get_contents("/tmp/pineap.conf");
+        $configFileOut = "";
+
+        $configFile = explode("\n", $configFile);
+        foreach($configFile as $row => $data) {
+            $entry = str_replace(" ", "", $data);
+            $entry = explode("=", $entry);
+
+            if ($entry[0] == $settingKey) {
+                $entry[1] = $settingVal;
+            }
+
+            if ($entry[0] != "" && $entry[1] != "") {
+                $configFileOut .= $entry[0] . " = " . $entry[1] . "\n";
+            }
+        }
+
+        file_put_contents("/tmp/pineap.conf", "");
+        file_put_contents("/tmp/pineap.conf", $configFileOut);
+
+        return true;
+    }
+
+    public function enableAssociations()
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            exec("pineap /tmp/pineap.conf karma on");
+        } else {
+            $this->setSetting("karma", "on");
+        }
+
+        return true;
+    }
+
+    public function disableAssociations()
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            exec("pineap /tmp/pineap.conf karma off");
+        } else {
+            $this->setSetting("karma", "off");
+        }
+
+        return true;
+    }
+
+    public function enablePineAP()
+    {
+        exec('/etc/init.d/pineapd start');
+        return true;
+    }
+
+    public function disablePineAP()
+    {
+        exec('/etc/init.d/pineapd stop');
+        return true;
+    }
+
+    public function enableLogging()
+    {
+        if (\helper\checkRunning('/usr/sbin/pineapd')) {
+            exec("pineap /tmp/pineap.conf logging on");
+        } else {
+            $this->setSetting("logging", "on");
+        }
+
+        return true;
+    }
+
+    public function disableLogging()
+    {
+        $this->setSetting("logging", "off");
+        if (\helper\checkRunning('/usr/sbin/pineapd')) {
+            exec("pineap /tmp/pineap.conf logging off");
+        }
+        return true;
+    }
+
+    public function enableBeaconer()
+    {
+        $this->setSetting("broadcast_ssid_pool", "on");
+        if (\helper\checkRunning('/usr/sbin/pineapd')) {
+            exec('pineap /tmp/pineap.conf broadcast_pool on');
+        }
+        return true;
+    }
+
+    public function disableBeaconer()
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            exec('pineap /tmp/pineap.conf broadcast_pool off');
+        } else {
+            $this->setSetting("broadcast_ssid_pool", "off");
+        }
+        return true;
+    }
+
+    public function enableResponder()
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            exec('pineap /tmp/pineap.conf beacon_responses on');
+        } else {
+            $this->setSetting("beacon_responses", "on");
+        }
+        return true;
+    }
+
+    public function disableResponder()
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            exec('pineap /tmp/pineap.conf beacon_responses off');
+        } else {
+            $this->setSetting("beacon_responses", "off");
+        }
+        return true;
+    }
+
+    public function enableHarvester()
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            exec('pineap /tmp/pineap.conf capture_ssids on');
+        } else {
+            $this->setSetting("capture_ssids", "on");
+        }
+        return true;
+    }
+
+    public function disableHarvester()
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            exec('pineap /tmp/pineap.conf capture_ssids off');
+        } else {
+            $this->setSetting("capture_ssids", "off");
+        }
+        return true;
+    }
+
+    public function enableConnectNotifications()
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            exec('pineap /tmp/pineap.conf connect_notifications on');
+        } else {
+            $this->setSetting("connect_notifications", "on");
+        }
+        return true;
+    }
+
+    public function disableConnectNotifications()
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            exec('pineap /tmp/pineap.conf connect_notifications off');
+        } else {
+            $this->setSetting("connect_notifications", "off");
+        }
+        return true;
+    }
+
+    public function enableDisconnectNotifications()
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            exec('pineap /tmp/pineap.conf disconnect_notifications on');
+        } else {
+            $this->setSetting("disconnect_notifications", "on");
+        }
+        return true;
+    }
+
+    public function disableDisconnectNotifications()
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            exec('pineap /tmp/pineap.conf disconnect_notifications off');
+        } else {
+            $this->setSetting("disconnect_notifications", "off");
+        }
+        return true;
+    }
+
+    public function getTarget()
+    {
+        return $this->getSetting("target_mac");
+    }
+
+    public function getSource()
+    {
+        return $this->getSetting("pineap_mac");
+    }
+
+    public function setBeaconInterval($interval)
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            $interval = escapeshellarg($interval);
+            exec("pineap /tmp/pineap.conf beacon_interval {$interval}");
+        } else {
+            $this->setSetting("beacon_interval", "{$interval}");
+        }
+
+        return;
+    }
+
+    public function setResponseInterval($interval)
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            $interval = escapeshellarg($interval);
+            exec("pineap /tmp/pineap.conf beacon_response_interval {$interval}");
+        } else {
+            $this->setSetting("beacon_response_interval", "{$interval}");
+        }
+
+        return;
+    }
+
+    public function setSource($mac)
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            $mac = escapeshellarg($mac);
+            exec("pineap /tmp/pineap.conf set_source {$mac}");
+        } else {
+            $this->setSetting("pineap_mac", "{$mac}");
+        }
+
+        return;
+    }
+
+    public function setTarget($mac)
+    {
+        if (\helper\checkRunning("/usr/sbin/pineapd")) {
+            $mac = escapeshellarg($mac);
+            exec("pineap /tmp/pineap.conf set_target {$mac}");
+        } else {
+            $this->setSetting("target_mac", "{$mac}");
+        }
+        return;
+    }
+
+    public function deauth($target, $source, $channel, $multiplier = 1)
+    {
+        $channel = str_pad($channel, 2, "0", STR_PAD_LEFT);
+        exec("pineap /tmp/pineap.conf deauth $source $target $channel $multiplier");
+        return true;
+    }
+}

+ 892 - 0
src/pineapple/modules/PineAP/api/module.php

@@ -0,0 +1,892 @@
+<?php namespace pineapple;
+
+require_once('/pineapple/modules/PineAP/api/PineAPHelper.php');
+
+class PineAP extends SystemModule
+{
+    const EAP_USER_FILE = "/etc/pineape/hostapd-pineape.eap_user";
+
+    private $pineAPHelper;
+    private $dbConnection;
+
+    public function __construct($request)
+    {
+        parent::__construct($request, __CLASS__);
+        $this->pineAPHelper = new PineAPHelper();
+        $this->dbConnection = false;
+
+        $dbLocation = $this->uciGet("pineap.@config[0].ssid_db_path");
+        if (file_exists($dbLocation)) {
+            $this->dbConnection = new DatabaseConnection($dbLocation);
+        }
+    }
+
+    public function route()
+    {
+        switch ($this->request->action) {
+            case 'getPool':
+                $this->getPool();
+                break;
+
+            case 'clearPool':
+                $this->clearPool();
+                break;
+
+            case 'addSSID':
+                $this->addSSID();
+                break;
+
+            case 'addSSIDs':
+                $this->addSSIDs();
+                break;
+
+            case 'removeSSID':
+                $this->removeSSID();
+                break;
+
+            case 'getPoolLocation':
+                $this->getPoolLocation();
+                break;
+
+            case 'setPoolLocation':
+                $this->setPoolLocation();
+                break;
+
+            case 'clearSessionCounter':
+                $this->clearSessionCounter();
+                break;
+
+            case 'setPineAPSettings':
+                $this->setPineAPSettings();
+                break;
+
+            case 'getPineAPSettings':
+                $this->getPineAPSettings();
+                break;
+
+            case 'setEnterpriseSettings':
+                $this->setEnterpriseSettings();
+                break;
+
+            case 'getEnterpriseSettings':
+                $this->getEnterpriseSettings();
+                break;
+
+            case 'detectEnterpriseCertificate':
+                $this->detectEnterpriseCertificate();
+                break;
+
+            case 'generateEnterpriseCertificate':
+                $this->generateEnterpriseCertificate();
+                break;
+
+            case 'clearEnterpriseCertificate':
+                $this->clearEnterpriseCertificate();
+                break;
+
+            case 'clearEnterpriseDB':
+                $this->clearEnterpriseDB();
+                break;
+
+            case 'getEnterpriseData':
+                $this->getEnterpriseData();
+                break;
+
+            case 'startHandshakeCapture':
+                $this->startHandshakeCapture();
+                break;
+
+            case 'stopHandshakeCapture':
+                $this->stopHandshakeCapture();
+                break;
+
+            case 'getHandshake':
+                $this->getHandshake();
+                break;
+
+            case 'getAllHandshakes':
+                $this->getAllHandshakes();
+                break;
+
+            case 'checkCaptureStatus':
+                $this->checkCaptureStatus();
+                break;
+
+            case 'downloadHandshake':
+                $this->downloadHandshake();
+                break;
+
+            case 'downloadAllHandshakes':
+                $this->downloadAllHandshakes();
+                break;
+
+            case 'clearAllHandshakes':
+                $this->clearAllHandshakes();
+                break;
+
+            case 'deleteHandshake':
+                $this->deleteHandshake();
+                break;
+
+            case 'deauth':
+                $this->deauth();
+                break;
+
+            case 'enable':
+                $this->enable();
+                break;
+
+            case 'disable':
+                $this->disable();
+                break;
+
+            case 'enableAutoStart':
+                $this->enableAutoStart();
+                break;
+
+            case 'disableAutoStart':
+                $this->disableAutoStart();
+                break;
+
+            case 'downloadPineAPPool':
+                $this->downloadPineAPPool();
+                break;
+
+            case 'loadProbes':
+                $this->loadProbes();
+                break;
+
+            case 'inject':
+                $this->inject();
+                break;
+
+            case 'countSSIDs':
+                $this->countSSIDs();
+                break;
+
+            case 'downloadJTRHashes':
+                $this->downloadJTRHashes();
+                break;
+
+            case 'downloadHashcatHashes':
+                $this->downloadHashcatHashes();
+                break;
+        }
+    }
+
+    private function toggleComment($fileName, $lineNumber, $comment)
+    {
+        $data = file_get_contents($fileName);
+        $lines = explode("\n", $data);
+        $line = $lines[$lineNumber - 1];
+        if (substr($line, 0, 1) === "#") {
+            if ($comment) {
+                return;
+            }
+            $line = substr($line, 1);
+        } else {
+            if (!$comment) {
+                return;
+            }
+            $line = '#' . $line;
+        }
+        $lines[$lineNumber - 1] = $line;
+        file_put_contents($fileName, join("\n", $lines));
+    }
+
+    private function isCommented($fileName, $lineNumber)
+    {
+        $data = file_get_contents($fileName);
+        $lines = explode("\n", $data);
+        $line = $lines[$lineNumber - 1];
+        $ret = substr($line, 0, 1) === "#";
+        return $ret;
+    }
+
+    private function getDowngradeType()
+    {
+        if (!$this->isCommented(PineAP::EAP_USER_FILE, 6)) {
+            return "MSCHAPV2";
+        } else if (!$this->isCommented(PineAP::EAP_USER_FILE, 5)) {
+            return "GTC";
+        } else {
+            return "DISABLE";
+        }
+    }
+
+    private function enableMSCHAPV2Downgrade()
+    {
+        $this->toggleComment(PineAP::EAP_USER_FILE, 4, true);
+        $this->toggleComment(PineAP::EAP_USER_FILE, 5, true);
+        $this->toggleComment(PineAP::EAP_USER_FILE, 6, false);
+    }
+
+    private function enableGTCDowngrade()
+    {
+        $this->toggleComment(PineAP::EAP_USER_FILE, 4, true);
+        $this->toggleComment(PineAP::EAP_USER_FILE, 5, false);
+        $this->toggleComment(PineAP::EAP_USER_FILE, 6, true);
+    }
+
+    private function disableDowngrade()
+    {
+        $this->toggleComment(PineAP::EAP_USER_FILE, 4, false);
+        $this->toggleComment(PineAP::EAP_USER_FILE, 5, true);
+        $this->toggleComment(PineAP::EAP_USER_FILE, 6, true);
+    }
+
+    private function loadProbes()
+    {
+        if (!\helper\checkRunningFull("/usr/sbin/pineapd")) {
+            $this->response = array('success' => false, 'reason' => "not running");
+            return;
+        }
+
+        $mac = strtolower($this->request->mac);
+        $probesArray = array();
+
+        exec("/usr/bin/pineap list_probes ${mac}", $output);
+
+        foreach ($output as $probeSSID) {
+            array_push($probesArray, $probeSSID);
+        }
+
+        $this->response = array('success' => true, 'probes' => implode("\n", array_unique($probesArray)));
+    }
+
+    private function downloadPineAPPool()
+    {
+        $poolLocation = '/tmp/ssid_pool.txt';
+        $data = $this->getPoolData();
+        file_put_contents($poolLocation, $data);
+        $this->response = array("download" => $this->downloadFile($poolLocation));
+    }
+
+    private function countSSIDs()
+    {
+        $this->response = array(
+            'SSIDs' => substr_count($this->getPoolData(), "\n"),
+            'newSSIDs' => substr_count($this->getNewPoolData(), "\n")
+        );
+    }
+
+    private function enable()
+    {
+        $this->pineAPHelper->enablePineAP();
+        $this->response = array("success" => true);
+    }
+
+    private function disable()
+    {
+        $this->pineAPHelper->disablePineAP();
+        $this->response = array("success" => true);
+    }
+
+    private function enableAutoStart()
+    {
+        $this->uciSet("pineap.@config[0].autostart", 1);
+        $this->response = array("success" => true);
+    }
+
+    private function disableAutoStart()
+    {
+        $this->uciSet("pineap.@config[0].autostart", 0);
+        $this->response = array("success" => true);
+    }
+
+    private function checkPineAP()
+    {
+        if (!$this->checkRunningFull('/usr/sbin/pineapd')) {
+            $this->response = array('error' => 'Please start PineAP', 'success' => false);
+            return false;
+        }
+        return true;
+    }
+
+    private function deauth()
+    {
+        if ($this->checkPineAP()) {
+            $sta = $this->request->sta;
+            $clients = $this->request->clients;
+            $multiplier = intval($this->request->multiplier, 10);
+            $channel = $this->request->channel;
+
+            if (empty($clients)) {
+                $this->response = array('error' => 'This AP has no clients', 'success' => false);
+                return;
+            }
+
+            foreach ($clients as $client) {
+                $mac = $client;
+                if (isset($client->mac)) {
+                    $mac = $client->mac;
+                }
+                $success = $this->pineAPHelper->deauth($mac, $sta, $channel, $multiplier);
+            }
+
+            if ($success) {
+                $this->response = array('success' => true);
+            }
+        } else {
+            $this->response = array('error' => 'Please start PineAP', 'success' => false);
+        }
+    }
+
+    private function getPoolData()
+    {
+        $ssidPool = "";
+        $rows = $this->dbConnection->query('SELECT * FROM ssids;');
+        if (!isset($rows['databaseQueryError'])) {
+            foreach ($rows as $row) {
+                $ssidPool .= $row['ssid'] . "\n";
+            }
+        }
+        return $ssidPool;
+    }
+
+    private function getNewPoolData()
+    {
+        $ssidPool = "";
+        $rows = $this->dbConnection->query('SELECT * FROM ssids WHERE new_ssid=1;');
+        if (!isset($rows['databaseQueryError'])) {
+            foreach ($rows as $row) {
+                $ssidPool .= $row['ssid'] . "\n";
+            }
+        }
+        return $ssidPool;
+    }
+
+    private function getPool()
+    {
+        $this->response = array('ssidPool' => $this->getPoolData(), 'success' => true);
+    }
+
+    private function clearPool()
+    {
+        $this->checkPineAP();
+        $this->dbConnection->query('DELETE FROM ssids;');
+        $this->response = array('success' => true);
+    }
+
+    private function addSSID()
+    {
+        $this->checkPineAP();
+        $ssid = $this->request->ssid;
+        $created_date = date('Y-m-d H:i:s');
+        if (strlen($ssid) < 1 || strlen($ssid) > 32) {
+            $this->error = 'Your SSID must have a length greater than 1 and less than 32.';
+        } else {
+            @$this->dbConnection->query("INSERT INTO ssids (ssid, created_at) VALUES ('%s', '%s')", $ssid, $created_date);
+            $this->response = array('success' => true);
+        }
+    }
+
+    private function addSSIDs()
+    {
+        $this->checkPineAP();
+        $ssidList = $this->request->ssids;
+        $created_date = date('Y-m-d H:i:s');
+
+        foreach ($ssidList as $ssid) {
+            if (strlen($ssid) >= 1 && strlen($ssid) <= 32) {
+                @$this->dbConnection->query("INSERT INTO ssids (ssid, created_at) VALUES ('%s', '%s');", $ssid, $created_date);
+            }
+        }
+        $this->response = array('success' => true);
+    }
+
+    private function removeSSID()
+    {
+        $this->checkPineAP();
+        $ssid = $this->request->ssid;
+        if (strlen($ssid) < 1 || strlen($ssid) > 32) {
+            $this->error = 'Your SSID must have a length greater than 1 and less than 32.';
+        } else {
+            $this->dbConnection->query("DELETE FROM ssids WHERE ssid='%s';", $ssid);
+            $this->response = array('success' => true);
+        }
+    }
+
+    private function getPoolLocation()
+    {
+        $dbBasePath = dirname($this->uciGet("pineap.@config[0].ssid_db_path"));
+        $this->response = array('poolLocation' => $dbBasePath . "/");
+    }
+
+    private function setPoolLocation()
+    {
+        $dbLocation = dirname($this->request->location . '/fake_file');
+        $this->uciSet("pineap.@config[0].ssid_db_path", $dbLocation . '/pineapple.db');
+        $this->response = array('success' => true);
+    }
+
+    private function clearSessionCounter()
+    {
+        $ret = 0;
+        $output = array();
+        exec('/usr/sbin/resetssids', $output, $ret);
+        if ($ret !== 0) {
+            $this->error = "Could not clear SSID session counter.";
+        } else {
+            $this->response = array('success' => true);
+        }
+    }
+
+    private function getPineAPSettings()
+    {
+        $sourceMAC = $this->pineAPHelper->getSource();
+        $sourceMAC = $sourceMAC === false ? '00:00:00:00:00:00' : $sourceMAC;
+        $sourceMAC = strtoupper($sourceMAC);
+        $targetMAC = $this->pineAPHelper->getTarget();
+        $targetMAC = $targetMAC === false ? 'FF:FF:FF:FF:FF:FF' : $targetMAC;
+        $targetMAC = strtoupper($targetMAC);
+        $settings = array(
+            'allowAssociations' => $this->pineAPHelper->getSetting("karma"),
+            'logEvents' => $this->pineAPHelper->getSetting("logging"),
+            'pineAPDaemon' => $this->checkRunning("pineapd"),
+            'autostartPineAP' => $this->uciGet("pineap.@config[0].autostart"),
+            'beaconResponses' => $this->pineAPHelper->getSetting("beacon_responses"),
+            'captureSSIDs' => $this->pineAPHelper->getSetting("capture_ssids"),
+            'broadcastSSIDs' => $this->pineAPHelper->getSetting("broadcast_ssid_pool"),
+            'connectNotifications' => $this->pineAPHelper->getSetting("connect_notifications"),
+            'disconnectNotifications' => $this->pineAPHelper->getSetting("disconnect_notifications"),
+            'broadcastInterval' => $this->pineAPHelper->getSetting("beacon_interval"),
+            'responseInterval' => $this->pineAPHelper->getSetting("beacon_response_interval"),
+            'sourceMAC' => $sourceMAC,
+            'targetMAC' => $targetMAC
+        );
+        $this->response = array('settings' => $settings, 'success' => true);
+        return $settings;
+    }
+
+    private function setPineAPSettings()
+    {
+        $settings = $this->request->settings;
+        if ($settings->allowAssociations) {
+            $this->pineAPHelper->enableAssociations();
+            $this->uciSet("pineap.@config[0].karma", 'on');
+        } else {
+            $this->pineAPHelper->disableAssociations();
+            $this->uciSet("pineap.@config[0].karma", 'off');
+        }
+        if ($settings->logEvents) {
+            $this->pineAPHelper->enableLogging();
+            $this->uciSet("pineap.@config[0].logging", 'on');
+        } else {
+            $this->pineAPHelper->disableLogging();
+            $this->uciSet("pineap.@config[0].logging", 'off');
+        }
+        if ($settings->beaconResponses) {
+            $this->pineAPHelper->enableResponder();
+            $this->uciSet("pineap.@config[0].beacon_responses", 'on');
+        } else {
+            $this->pineAPHelper->disableResponder();
+            $this->uciSet("pineap.@config[0].beacon_responses", 'off');
+        }
+        if ($settings->captureSSIDs) {
+            $this->pineAPHelper->enableHarvester();
+            $this->uciSet("pineap.@config[0].capture_ssids", 'on');
+        } else {
+            $this->pineAPHelper->disableHarvester();
+            $this->uciSet("pineap.@config[0].capture_ssids", 'off');
+        }
+        if ($settings->broadcastSSIDs) {
+            $this->pineAPHelper->enableBeaconer();
+            $this->uciSet("pineap.@config[0].broadcast_ssid_pool", 'on');
+        } else {
+            $this->pineAPHelper->disableBeaconer();
+            $this->uciSet("pineap.@config[0].broadcast_ssid_pool", 'off');
+        }
+        if ($settings->connectNotifications) {
+            $this->pineAPHelper->enableConnectNotifications();
+            $this->uciSet("pineap.@config[0].connect_notifications", 'on');
+        } else {
+            $this->pineAPHelper->disableConnectNotifications();
+            $this->uciSet("pineap.@config[0].connect_notifications", 'off');
+        }
+        if ($settings->disconnectNotifications) {
+            $this->pineAPHelper->enableDisconnectNotifications();
+            $this->uciSet("pineap.@config[0].disconnect_notifications", 'on');
+        } else {
+            $this->pineAPHelper->disableDisconnectNotifications();
+            $this->uciSet("pineap.@config[0].disconnect_notifications", 'off');
+        }
+        $this->pineAPHelper->setBeaconInterval($settings->broadcastInterval);
+        $this->uciSet("pineap.@config[0].beacon_interval", $settings->broadcastInterval);
+        $this->pineAPHelper->setResponseInterval($settings->responseInterval);
+        $this->uciSet("pineap.@config[0].beacon_response_interval", $settings->responseInterval);
+        $this->pineAPHelper->setTarget($settings->targetMAC);
+        $this->uciSet("pineap.@config[0].target_mac", $settings->targetMAC);
+        $this->pineAPHelper->setSource($settings->sourceMAC);
+        $this->uciSet("pineap.@config[0].pineap_mac", $settings->sourceMAC);
+
+        $this->response = array("success" => true);
+    }
+
+
+    private function detectEnterpriseCertificate()
+    {
+        if (file_exists('/etc/pineape/certs/server.crt')) {
+            $this->response = array("installed" => true);
+        } else {
+            $this->response = array("installed" => false);
+        }
+    }
+
+    private function generateEnterpriseCertificate()
+    {
+        $params = $this->request->certSettings;
+
+        $state = $params->state;
+        $country = $params->country;
+        $locality = $params->locality;
+        $organization = $params->organization;
+        $email = $params->email;
+        $commonname = $params->commonname;
+
+        if ((strlen($state) < 1 || strlen($state) > 32) ||
+            (strlen($country) < 2 || strlen($country) > 2) ||
+            (strlen($locality) < 1 || strlen($locality) > 32) ||
+            (strlen($organization) < 1 || strlen($organization) > 32) ||
+            (strlen($email) < 1 || strlen($email) > 32) ||
+            (strlen($commonname) < 1 || strlen($commonname) > 32)) {
+            $this->error = "Invalid settings provided.";
+            return;
+        }
+
+        $state = escapeshellarg($params->state);
+        $country = escapeshellarg($params->country);
+        $locality = escapeshellarg($params->locality);
+        $organization = escapeshellarg($params->organization);
+        $email = escapeshellarg($params->email);
+        $commonname = escapeshellarg($params->commonname);
+
+        exec("cd /etc/pineape/certs && ./clean.sh");
+        exec("/etc/pineape/certs/configure.sh -p pineapplesareyummy -c ${country} -s ${state} -l ${locality} -o ${organization} -e ${email} -n ${commonname}");
+        $this->execBackground("/etc/pineape/certs/bootstrap.sh");
+
+        $this->response = array("success" => true);
+    }
+
+    private function clearEnterpriseCertificate()
+    {
+        exec("cd /etc/pineape/certs && ./clean.sh");
+        $this->uciSet("wireless.@wifi-iface[2].disabled", "1");
+        $this->execBackground("wifi down radio0 && wifi up radio0");
+        $this->response = array("success" => true);
+    }
+
+    private function clearEnterpriseDB()
+    {
+        $dbLocation = "/etc/pineapple/pineape.db";
+        $this->dbConnection = new DatabaseConnection($dbLocation);
+
+        $this->dbConnection->exec("DELETE FROM chalresp; DELETE FROM basic;");
+        $this->response = array("success" => true);
+    }
+
+    private function getEnterpriseSettings()
+    {
+        $settings = array(
+            'enabled' => $this->getEnterpriseRunning(),
+            'enableAssociations' => $this->getEnterpriseAllowAssocs(),
+            'ssid' => $this->uciGet('wireless.@wifi-iface[2].ssid'),
+            'mac' => $this->uciGet('wireless.@wifi-iface[2].macaddr'),
+            'encryptionType' => $this->uciGet('wireless.@wifi-iface[2].encryption'),
+            'downgrade' => $this->getDowngradeType(),
+        );
+
+        $this->response = array("settings" => $settings);
+    }
+
+    private function setEnterpriseSettings()
+    {
+        $settings = $this->request->settings;
+        if ((strlen($settings->ssid) < 1 || strlen($settings->ssid) > 32) ||
+            (strlen($settings->mac) < 17 || strlen($settings->mac) > 17)) {
+            $this->error = "Invalid settings provided.";
+            return;
+        }
+        $this->uciSet("wireless.@wifi-iface[2].ssid", $settings->ssid);
+        $this->uciSet("wireless.@wifi-iface[2].macaddr", $settings->mac);
+        $this->uciSet("wireless.@wifi-iface[2].encryption", $settings->encryptionType);
+        if ($settings->enabled) {
+            $this->uciSet("wireless.@wifi-iface[2].disabled", "0");
+        } else {
+            $this->uciSet("wireless.@wifi-iface[2].disabled", "1");
+        }
+
+        if ($settings->enableAssociations) {
+            $this->uciSet("pineap.@config[0].pineape_passthrough", "on");
+        } else {
+            $this->uciSet("pineap.@config[0].pineape_passthrough", "off");
+        }
+
+        switch (strtoupper($settings->downgrade)) {
+            case "MSCHAPV2":
+                $this->enableMSCHAPV2Downgrade();
+                break;
+            case "GTC":
+                $this->enableGTCDowngrade();
+                break;
+            case "DISABLE":
+            default:
+                $this->disableDowngrade();
+        }
+
+        $this->execBackground("wifi down radio0 && wifi up radio0");
+        $this->response = array("success" => true);
+    }
+
+    private function getEnterpriseData()
+    {
+        $dbLocation = "/etc/pineapple/pineape.db";
+        $this->dbConnection = new DatabaseConnection($dbLocation);
+
+        $chalrespdata = array();
+        $rows = $this->dbConnection->query("SELECT type, username, hex(challenge), hex(response) FROM chalresp;");
+        foreach ($rows as $row) {
+            $x = array();
+            $x['type'] = $row['type'];
+            $x['username'] = $row['username'];
+            $x['challenge'] = $row['hex(challenge)'];
+            $x['response'] = $row['hex(response)'];
+            array_push($chalrespdata, $x);
+        }
+
+        $basicdata = array();
+        $rows = $this->dbConnection->query("SELECT type, identity, password FROM basic;");
+        foreach ($rows as $row) {
+            $x = array();
+            $x['type'] = $row['type'];
+            $x['username'] = $row['identity'];
+            $x['password'] = $row['password'];
+            array_push($basicdata, $x);
+        }
+        $this->response = array("success" => true, "chalrespdata" => $chalrespdata, "basicdata" => $basicdata);
+    }
+
+    private function downloadJTRHashes()
+    {
+        $jtrLocation = '/tmp/enterprise_jtr.txt';
+        $dbLocation = "/etc/pineapple/pineape.db";
+        $this->dbConnection = new DatabaseConnection($dbLocation);
+        $data = array();
+        $rows = $this->dbConnection->query("SELECT type, username, hex(challenge), hex(response) FROM chalresp;");
+        foreach ($rows as $row) {
+            if (strtoupper($row['type']) !== "MSCHAPV2" && strtoupper($row['type']) != "EAP-TTLS/MSCHAPV2") {
+                continue;
+            }
+            $x = $row['username'] . ':$NETNTLM$' . $row['hex(challenge)'] . '$' . $row['hex(response)'];
+            array_push($data, $x);
+        }
+        file_put_contents($jtrLocation, join("\n", $data));
+        $this->response = array("download" => $this->downloadFile($jtrLocation));
+    }
+
+    private function downloadHashcatHashes()
+    {
+        $hashcatLocation = '/tmp/enterprise_hashcat.txt';
+        $dbLocation = "/etc/pineapple/pineape.db";
+        $this->dbConnection = new DatabaseConnection($dbLocation);
+        $data = array();
+        $rows = $this->dbConnection->query("SELECT type, username, hex(challenge), hex(response) FROM chalresp;");
+        foreach ($rows as $row) {
+            if (strtoupper($row['type']) !== "MSCHAPV2" && strtoupper($row['type']) != "EAP-TTLS/MSCHAPV2") {
+                continue;
+            }
+            $x = $row['username'] . '::::' . $row['hex(response)'] . ':' . $row['hex(challenge)'];
+            array_push($data, $x);
+        }
+        file_put_contents($hashcatLocation, join("\n", $data));
+        $this->response = array("download" => $this->downloadFile($hashcatLocation));
+    }
+
+    private function getEnterpriseRunning()
+    {
+        exec("hostapd_cli -i wlan0-2 pineape_enable_status", $statusOutput);
+        if ($statusOutput[0] == "ENABLED") {
+            return true;
+        }
+
+        return false;
+    }
+
+    private function getEnterpriseAllowAssocs()
+    {
+        exec("hostapd_cli -i wlan0-2 pineape_auth_passthrough_status", $statusOutput);
+        if ($statusOutput[0] == "ENABLED") {
+            return true;
+        }
+        return false;
+    }
+
+    private function startHandshakeCapture()
+    {
+        $bssid = $this->request->bssid;
+        $channel = $this->request->channel;
+        if ($this->checkPineAP()) {
+            $this->execBackground("pineap /etc/pineap.conf handshake_capture_start ${bssid} ${channel}");
+            $this->response = array('success' => true);
+            return;
+        } else {
+            // We already set $this->response in checkPineAP() if it isnt running.
+            return;
+        }
+    }
+
+    private function stopHandshakeCapture()
+    {
+        $this->execBackground('pineap /tmp/pineap.conf handshake_capture_stop');
+        $this->response = array('success' => true);
+    }
+
+    private function getHandshake()
+    {
+        $bssid = str_replace(':', '-', $this->request->bssid);
+        if (file_exists("/tmp/handshakes/{$bssid}_full.pcap")) {
+            $this->response = array('handshakeExists' => true, 'partial' => false);
+        } else if (file_exists("/tmp/handshakes/{$bssid}_partial.pcap")) {
+            $this->response = array('handshakeExists' => true, 'partial' => true);
+        } else {
+            $this->response = array('handshakeExists' => false);
+        }
+    }
+
+    private function getAllHandshakes()
+    {
+        $handshakes = array();
+        foreach (glob("/tmp/handshakes/*.pcap") as $handshake) {
+            $handshake = str_replace("/tmp/handshakes/", "", $handshake);
+            $handshake = str_replace("_full.pcap", "", $handshake);
+            $handshake = str_replace("_partial.pcap", "", $handshake);
+            $handshake = str_replace('-', ':', $handshake);
+            array_push($handshakes, $handshake);
+        }
+
+        $this->response = array("handshakes" => $handshakes);
+    }
+
+    private function downloadAllHandshakes()
+    {
+        @unlink('/tmp/handshakes/handshakes.tar.gz');
+        exec("tar -czf /tmp/handshakes/handshakes.tar.gz -C /tmp/handshakes .");
+        $this->response = array("download" => $this->downloadFile("/tmp/handshakes/handshakes.tar.gz"));
+    }
+
+    private function clearAllHandshakes()
+    {
+        @unlink("/tmp/handshakes/handshakes.tar.gz");
+        foreach (glob("/tmp/handshakes/*.pcap") as $handshake) {
+            unlink($handshake);
+        }
+
+        $this->response = array("success" => true);
+    }
+
+    private function checkCaptureStatus()
+    {
+        $bssid = $this->request->bssid;
+        exec("pineap /tmp/pineap.conf get_status", $status_output);
+        if ($status_output[0] === "PineAP is not running") {
+            $this->error = "PineAP is not running";
+            return 0;
+        } else {
+            $status_output = implode("\n", $status_output);
+            $status_output = json_decode($status_output, true);
+            if ($status_output['captureRunning'] === true && $status_output['bssid'] === $bssid) {
+                // A scan is running for the supplied BSSID.
+                $this->response = array('running' => true, 'currentBSSID' => true, 'bssid' => $status_output['bssid']);
+                return 3;
+            } elseif ($status_output['captureRunning'] === true) {
+                // A scan is running, but not for this BSSID.
+                $this->response = array('running' => true, 'currentBSSID' => false, 'bssid' => $status_output['bssid']);
+                return 2;
+            } else {
+                // No scan is running.
+                $this->response = array('running' => false, 'currentBSSID' => false);
+                return 0;
+            }
+        }
+    }
+
+    private function downloadHandshake()
+    {
+        $bssid = str_replace(':', '-', $this->request->bssid);
+        $type = $this->request->type;
+        // JTR and hashcat don't care whether the data came from a full or partial handshake
+        if ($type === "pcap") {
+            $suffix = "_";
+            if (file_exists("/tmp/handshakes/{$bssid}_full.pcap")) {
+                $suffix .= "full";
+            } else {
+                $suffix .= "partial";
+            }
+            $this->response = array("download" => $this->downloadFile("/tmp/handshakes/{$bssid}{$suffix}.pcap"));
+        } else {
+            $this->response = array("download" => $this->downloadFile("/tmp/handshakes/{$bssid}.{$type}"));
+        }
+    }
+
+    private function deleteHandshake()
+    {
+        $bssid = str_replace(':', '-', $this->request->bssid);
+        @unlink("/tmp/handshakes/${bssid}_full.pcap");
+        @unlink("/tmp/handshakes/${bssid}_partial.pcap");
+        @unlink("/tmp/handshakes/${bssid}.hccap");
+        @unlink("/tmp/handshakes/${bssid}.txt");
+
+        $this->response = array('success' => true);
+    }
+
+    private function inject()
+    {
+        $payload = preg_replace('/[^A-Fa-f0-9]/', '', $this->request->payload);
+        if (hex2bin($payload) === false) {
+            $this->error = 'Invalid hex';
+            return;
+        }
+        if (!$this->checkRunningFull('/usr/sbin/pineapd')) {
+            $this->error = 'Please start PineAP';
+            return;
+        }
+        file_put_contents('/tmp/inject', $payload);
+        $channel = intval($this->request->channel);
+        $frameCount = intval($this->request->frameCount);
+        $delay = intval($this->request->delay);
+        $descriptorspec = array(
+            0 => array("pipe", "r"),
+            1 => array("pipe", "w"),
+            2 => array("pipe", "w"),
+        );
+        $cmd = "/usr/bin/pineap /tmp/pineap.conf inject /tmp/inject ${channel} ${frameCount} ${delay}";
+        $process = proc_open($cmd, $descriptorspec, $pipes);
+        if (!is_resource($process)) {
+            $this->response = array('error' => "Failed to spawn process for command: ${cmd}", 'command' => $cmd);
+            unlink('/tmp/inject');
+            return;
+        }
+        fwrite($pipes[0], $payload);
+        fclose($pipes[0]);
+        $output = stream_get_contents($pipes[1]);
+        $errorOutput = stream_get_contents($pipes[2]);
+        $exitCode = proc_close($process);
+        if (empty($output)) {
+            $this->response = array(
+                'success' => true,
+                'request' => $this->request,
+                'payload' => json_encode($payload),
+                'command' => $cmd
+            );
+        } else {
+            $this->response = array(
+                'error' => 'PineAP cli did not execute successfully',
+                'command' => $cmd,
+                'exitCode' => $exitCode,
+                'stdout' => $output,
+                'stderr' => $errorOutput
+            );
+        }
+        unlink('/tmp/inject');
+    }
+}

+ 22 - 0
src/pineapple/modules/PineAP/executable/executable

@@ -0,0 +1,22 @@
+#!/bin/bash
+# Simple wrapper script for PineAP
+
+ps | grep [p]ineapd -q && {
+    [[ "$1" == "stop" ]] && {
+        echo [*] Stopping PineAP
+        /etc/init.d/pineapd stop &> /dev/null
+        echo [*] PineAP successfully stopped
+        exit 0
+    } || {
+        echo -e [*] Executing /usr/bin/pineap /tmp/pineap.conf "$@" "\n"
+        /usr/bin/pineap /tmp/pineap.conf "$@"
+    }
+} || {
+    [[ "$1" == "start" ]] && {
+        echo "[*] Starting PineAP, please wait."
+        /etc/init.d/pineapd start &> /dev/null
+        echo "[*] PineAP started successfully"
+    } || {
+        echo "PineAPd is not running. Start it with 'module PineAP start'"
+    }
+}

部分文件因为文件数量过多而无法显示