diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4de78f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +module.sig diff --git a/Api/Gql/Gateway.php b/Api/Gql/Gateway.php new file mode 100644 index 0000000..592297f --- /dev/null +++ b/Api/Gql/Gateway.php @@ -0,0 +1,429 @@ + [ + 'description' => _('Read Gateways'), + ], + 'write:gateway' => [ + 'description' => _('Write Gateways'), + ] + ]; + } + + /** + * mutationCallback + * + * @return callable|void + */ + public function mutationCallback() { + if($this->checkWriteScope("gateway")) { + return function() { + return [ + 'addGateway' => Relay::mutationWithClientMutationId([ + 'name' => 'addGateway', + 'description' => _('Add a new gateway'), + 'inputFields' => $this->getAddInputFields(), + 'outputFields' => $this->getMutationOutputFields(), + 'mutateAndGetPayload' => function ($input) { + $data = $this->resolveGatewayInput($input, null); + $result = $this->freepbx->Gateway->addGateway($data); + return [ + 'gateway' => $result['gateway'], + 'status' => $result['status'], + 'message' => $result['message'], + ]; + } + ]), + 'updateGateway' => Relay::mutationWithClientMutationId([ + 'name' => 'updateGateway', + 'description' => _('Update an existing gateway. Fields left out keep their current value.'), + 'inputFields' => $this->getUpdateInputFields(), + 'outputFields' => $this->getMutationOutputFields(), + 'mutateAndGetPayload' => function ($input) { + $extension = (string)($input['extension'] ?? ''); + $existing = $extension !== '' ? $this->freepbx->Gateway->getGateway($extension) : null; + if(empty($existing)){ + return ['gateway' => null, 'status' => false, 'message' => _('Gateway not found!')]; + } + $data = $this->resolveGatewayInput($input, $existing); + $result = $this->freepbx->Gateway->updateGateway($data); + return [ + 'gateway' => $result['gateway'], + 'status' => $result['status'], + 'message' => $result['message'], + ]; + } + ]), + 'removeGateway' => Relay::mutationWithClientMutationId([ + 'name' => 'removeGateway', + 'description' => _('Remove an existing gateway'), + 'inputFields' => [ + 'extension' => [ + 'type' => Type::nonNull(Type::id()), + 'description' => _('Extension of the gateway to remove'), + ] + ], + 'outputFields' => [ + 'deletedId' => [ + 'type' => Type::id(), + 'description' => _('Extension of the gateway that was removed'), + 'resolve' => function ($payload) { + return $payload['extension']; + } + ], + 'status' => [ + 'type' => Type::boolean(), + 'description' => _('Status of the request'), + ], + 'message' => [ + 'type' => Type::string(), + 'description' => _('Message for the request'), + ], + ], + 'mutateAndGetPayload' => function ($input) { + $result = $this->freepbx->Gateway->deleteGateway($input['extension']); + return [ + 'extension' => $input['extension'], + 'status' => $result['status'], + 'message' => $result['message'], + ]; + } + ]), + ]; + }; + } + } + + /** + * queryCallback + * + * @return callable|void + */ + public function queryCallback() { + if($this->checkReadScope("gateway")) { + return function() { + return [ + 'allGateways' => [ + 'type' => $this->typeContainer->get('gateway')->getConnectionType(), + 'description' => $this->description, + 'args' => Relay::connectionArgs(), + 'resolve' => function($root, $args) { + return Relay::connectionFromArray($this->freepbx->Gateway->getAllGateway(), $args); + }, + ], + 'gateway' => [ + 'type' => $this->typeContainer->get('gateway')->getObject(), + 'description' => _('Fetch a single gateway by extension'), + 'args' => [ + 'id' => [ + 'type' => Type::nonNull(Type::id()), + 'description' => _('Extension of the gateway'), + ] + ], + 'resolve' => function($root, $args) { + $row = $this->freepbx->Gateway->getGateway($args['id']); + return !empty($row) ? $row : null; + } + ], + ]; + }; + } + } + + /** + * initializeTypes + * + * @return void + */ + public function initializeTypes() { + $gateway = $this->typeContainer->create('gateway'); + $gateway->setDescription($this->description); + + $gateway->addInterfaceCallback(function() { + return [$this->getNodeDefinition()['nodeInterface']]; + }); + + $gateway->setGetNodeCallback(function($id) { + $row = $this->freepbx->Gateway->getGateway($id); + return !empty($row) ? $row : null; + }); + + $gateway->addFieldCallback(function() { + return [ + 'id' => [ + 'type' => Type::nonNull(Type::id()), + 'description' => _('Extension used as the gateway id'), + 'resolve' => function($row) { + return $row['extension']; + } + ], + 'extension' => [ + 'type' => Type::nonNull(Type::string()), + 'description' => _('Extension as Gateway'), + ], + 'contact' => [ + 'type' => Type::string(), + 'description' => _('Name of the contact'), + ], + 'description' => [ + 'type' => Type::string(), + 'description' => _('Description of the gateway'), + ], + 'address' => [ + 'type' => Type::string(), + 'description' => _('Address of the contact'), + ], + 'city' => [ + 'type' => Type::string(), + 'description' => _('City of the contact'), + ], + 'zipCode' => [ + 'type' => Type::string(), + 'description' => _('ZIP code of the contact'), + 'resolve' => function($row) { + return $row['zip_code'] ?? ''; + } + ], + 'country' => [ + 'type' => Type::string(), + 'description' => _('Country of the contact'), + ], + 'email' => [ + 'type' => Type::string(), + 'description' => _('Email of the contact'), + ], + 'gatewayAddress' => [ + 'type' => Type::string(), + 'description' => _('IP address (with optional :port) of the remote gateway'), + 'resolve' => function($row) { + return $row['gateway'] ?? ''; + } + ], + 'accountcode' => [ + 'type' => Type::string(), + 'description' => _('Account code applied to calls through this gateway'), + ], + 'callLimit' => [ + 'type' => Type::int(), + 'description' => _('Maximum number of simultaneous calls (0 = unlimited)'), + 'resolve' => function($row) { + return intval($row['call_limit'] ?? 0); + } + ], + 'dids' => [ + 'type' => Type::listOf(Type::string()), + 'description' => _('DIDs attached to this gateway (first one is the DID base)'), + 'resolve' => function($row) { + $dids = json_decode($row['dids'] ?? '[]', true); + return is_array($dids) ? array_values($dids) : []; + } + ], + 'status' => [ + 'type' => Type::string(), + 'description' => _('Live PJSIP connectivity status: online, offline or unknown'), + 'resolve' => function($row) { + return $this->freepbx->Gateway->getEndpointStatus($row['extension']); + } + ], + ]; + }); + + $gateway->setConnectionResolveNode(function ($edge) { + return $edge['node']; + }); + + $gateway->setConnectionFields(function() { + return [ + 'totalCount' => [ + 'type' => Type::int(), + 'resolve' => function($value) { + return count($this->freepbx->Gateway->getAllGateway()); + } + ], + 'gateways' => [ + 'type' => Type::listOf($this->typeContainer->get('gateway')->getObject()), + 'description' => $this->description, + 'resolve' => function($root, $args) { + return array_map(function($row) { + return $row['node']; + }, $root['edges']); + } + ] + ]; + }); + } + + /** + * getMutationOutputFields + * + * Shared output shape for addGateway/updateGateway mutations. + * + * @return array + */ + private function getMutationOutputFields() { + return [ + 'gateway' => [ + 'type' => $this->typeContainer->get('gateway')->getObject(), + 'description' => _('The gateway after the change'), + 'resolve' => function ($payload) { + return $payload['gateway']; + } + ], + 'status' => [ + 'type' => Type::boolean(), + 'description' => _('Status of the request'), + ], + 'message' => [ + 'type' => Type::string(), + 'description' => _('Message for the request'), + ], + ]; + } + + /** + * getAddInputFields + * + * @return array + */ + private function getAddInputFields() { + return [ + 'extension' => [ + 'type' => Type::nonNull(Type::id()), + 'description' => _('Extension to use as the gateway. Must be an existing PJSIP extension not already used by another gateway.'), + ], + 'contact' => [ + 'type' => Type::nonNull(Type::string()), + 'description' => _('Name of the contact'), + ], + 'description' => [ + 'type' => Type::string(), + 'description' => _('Description of the gateway'), + ], + 'address' => [ + 'type' => Type::string(), + 'description' => _('Address of the contact'), + ], + 'city' => [ + 'type' => Type::string(), + 'description' => _('City of the contact'), + ], + 'zip' => [ + 'type' => Type::string(), + 'description' => _('ZIP code of the contact (digits only, 3 to 6 characters)'), + ], + 'country' => [ + 'type' => Type::string(), + 'description' => _('Country of the contact'), + ], + 'email' => [ + 'type' => Type::string(), + 'description' => _('Email of the contact'), + ], + 'gatewayAddress' => [ + 'type' => Type::nonNull(Type::string()), + 'description' => _('IPv4 address of the remote gateway, with an optional :port (e.g. 200.25.46.30 or 200.25.46.30:5061)'), + ], + 'accountcode' => [ + 'type' => Type::string(), + 'description' => _('Account code applied to calls through this gateway (letters, digits, "_" and "-" only)'), + ], + 'callLimit' => [ + 'type' => Type::int(), + 'description' => _('Maximum number of simultaneous calls (0 = unlimited)'), + ], + 'dids' => [ + 'type' => Type::nonNull(Type::listOf(Type::nonNull(Type::string()))), + 'description' => _('DIDs attached to this gateway. The first entry is the DID base (usually the same as the extension).'), + ], + ]; + } + + /** + * getUpdateInputFields + * + * Same as getAddInputFields() but nothing besides "extension" is + * required - any field left out keeps its current stored value. + * + * @return array + */ + private function getUpdateInputFields() { + $fields = $this->getAddInputFields(); + $fields['extension'] = [ + 'type' => Type::nonNull(Type::id()), + 'description' => _('Extension of the gateway to update'), + ]; + $fields['gatewayAddress'] = [ + 'type' => Type::string(), + 'description' => _('IPv4 address of the remote gateway, with an optional :port (e.g. 200.25.46.30 or 200.25.46.30:5061)'), + ]; + $fields['dids'] = [ + 'type' => Type::listOf(Type::nonNull(Type::string())), + 'description' => _('DIDs attached to this gateway. The first entry is the DID base. Omit to keep the current list.'), + ]; + return $fields; + } + + /** + * resolveGatewayInput + * + * Maps the camelCase GraphQL input onto the internal array shape + * expected by Gateway::addGateway()/updateGateway() (itself shared + * with the web form), falling back to the existing DB row for any + * field omitted from the input - so updateGateway is a real partial + * update instead of blanking out everything that wasn't passed. + * + * @param array $input the GraphQL mutation input + * @param array|null $existing the current DB row (null when adding) + * @return array + */ + private function resolveGatewayInput($input, $existing) { + $existing = $existing ?? []; + $existingDids = isset($existing['dids']) ? json_decode($existing['dids'], true) : []; + $existingDids = is_array($existingDids) ? $existingDids : []; + + return [ + 'extension' => $input['extension'] ?? ($existing['extension'] ?? ''), + 'contact' => $input['contact'] ?? ($existing['contact'] ?? ''), + 'description' => $input['description'] ?? ($existing['description'] ?? ''), + 'address' => $input['address'] ?? ($existing['address'] ?? ''), + 'city' => $input['city'] ?? ($existing['city'] ?? ''), + 'zip' => $input['zip'] ?? ($existing['zip_code'] ?? ''), + 'country' => $input['country'] ?? ($existing['country'] ?? ''), + 'email' => $input['email'] ?? ($existing['email'] ?? ''), + 'gateway' => $input['gatewayAddress'] ?? ($existing['gateway'] ?? ''), + 'accountcode' => $input['accountcode'] ?? ($existing['accountcode'] ?? ''), + 'call_limit' => $input['callLimit'] ?? ($existing['call_limit'] ?? 0), + 'dids' => $input['dids'] ?? $existingDids, + ]; + } +} diff --git a/Backup.php b/Backup.php new file mode 100644 index 0000000..1d8e3d6 --- /dev/null +++ b/Backup.php @@ -0,0 +1,10 @@ +FreePBX->Gateway->getAllGateway(); + $this->addConfigs($config); + } +} diff --git a/Gateway.class.php b/Gateway.class.php new file mode 100644 index 0000000..80a599c --- /dev/null +++ b/Gateway.class.php @@ -0,0 +1,713 @@ +FreePBX = $freepbx; + $this->db = $freepbx->Database; + $this->astman = $this->FreePBX->astman; + } + + //Install method. use this or install.php using both may cause weird behavior + //Runs on both fresh install and module upgrade: re-applies the PJSIP settings + //on every existing gateway so extensions created by older module versions + //(missing e.g. user_eq_phone) get backfilled instead of staying stale. + public function install() { + $gateways = $this->getAllGateway(); + foreach ($gateways as $gateway) { + $this->applyGatewaySipConfig($gateway["extension"], $gateway["gateway"], $gateway["accountcode"]); + } + if (!empty($gateways)) { + needreload(); + } + } + + //Uninstall method. use this or install.php using both may cause weird behavior + public function uninstall() { + $gateways = $this->getAllGateway(); + foreach($gateways as $gateway){ + $sql = "UPDATE sip SET data = 'from-internal' WHERE id = :extension AND keyword = 'context'"; + $stm = $this->db->prepare($sql); + $stm->execute(array(":extension" => $gateway["extension"])); + } + + $sql = "TRUNCATE TABLE gateway;"; + $this->db->prepare($sql)->execute(); + } + + //Not yet implemented + public function backup() {} + + //not yet implimented + public function restore($backup) {} + + //process form + public function doConfigPageInit($page) {} + + // Set by showPage() when it redisplays add_gateway.php/edit_gateway.php + // after a failed validate() (e.g. duplicate DID). That POST-back has no + // "action" in the request (the form posts to plain "?display=gateway"), + // so getActionBar() below has no way to know it should still show the + // Submit/Cancel buttons unless we tell it explicitly. + private $lastFailedFormAction = null; + + //This shows the submit buttons + public function getActionBar($request) { + $buttons = []; + $action = !empty($request["action"]) ? $request["action"] : $this->lastFailedFormAction; + if(!empty($action)){ + if($action === "help"){ + return $buttons; + } + } + else{ + return $buttons; + } + switch($_GET['display']) { + case 'gateway': + $buttons = array( + 'submit' => array( + 'name' => 'submit', + 'id' => 'submit', + 'value' => _('Submit') + ), + 'Cancel' => array( + 'name' => 'cancel', + 'id' => 'cancel', + 'value' => _('Cancel') + ), + ); + break; + } + return $buttons; + } + + public function showPage(){ + $request = freepbxGetSanitizedRequest(); + $lang = $_COOKIE["lang"]; + $jsloc = "[]"; + if( file_exists(__DIR__."/i18n/$lang/LC_MESSAGES/gateway.json")){ + $jsloc = file_get_contents(__DIR__."/i18n/$lang/LC_MESSAGES/gateway.json"); + } + $request["action"] = empty($request["action"]) ? "" : $request["action"]; + switch($request["action"]){ + case "add_gateway": + $vars = array('title' => _("Add Gateway")); + $vars["users"] = $this->getUsers(); + $vars["jsloc"] = $this->cleanJSLoc($jsloc); + return load_view(__DIR__.'/views/add_gateway.php',$vars); + case "edit_gateway": + $vars = array('title' => _("Edit Gateway")); + $vars["users"] = $this->getUsers(); + $vars["gateway"] = $this->getGateway($request["gateway"]); + $vars["accountcode"]= $vars["gateway"]["accountcode"] ?? ''; + $vars["jsloc"] = $this->cleanJSLoc($jsloc); + return load_view(__DIR__.'/views/edit_gateway.php',$vars); + case "help": + $vars = ["img" => "/admin/modules/gateway/"]; + $vars["jsloc"] = $this->cleanJSLoc($jsloc); + return load_view(__DIR__.'/views/description.php', $vars); + default: + $vars = ['title' => _("Gateway List")]; + $vars["jsloc"] = $this->cleanJSLoc($jsloc); + if(!empty($request["edit"]) && $request["edit"] === "no" ){ + $result = $this->addGateway($request); + if(!$result["status"]){ + // Stay on the "Add" form and keep what was typed instead + // of dropping the user back on the grid. + $addVars = array('title' => _("Add Gateway")); + $addVars["users"] = $this->getUsers(); + $addVars["jsloc"] = $this->cleanJSLoc($jsloc); + $addVars["message"] = $result["message"]; + $addVars["formdata"] = $request; + $this->lastFailedFormAction = "add_gateway"; + return load_view(__DIR__.'/views/add_gateway.php',$addVars); + } + } + + if(!empty($request["edit"]) && $request["edit"] === "yes" ){ + $result = $this->updateGateway($request); + if(!$result["status"]){ + // Stay on the "Edit" form and redisplay what was typed + // (not the stale DB row) instead of dropping the user + // back on the grid. + $editVars = array('title' => _("Edit Gateway")); + $editVars["users"] = $this->getUsers(); + $editVars["jsloc"] = $this->cleanJSLoc($jsloc); + $editVars["message"] = $result["message"]; + $editVars["accountcode"] = $request["accountcode"] ?? ''; + $editVars["gateway"] = array( + "extension" => $request["extension"] ?? '', + "contact" => $request["contact"] ?? '', + "description" => $request["description"] ?? '', + "address" => $request["address"] ?? '', + "city" => $request["city"] ?? '', + "zip_code" => $request["zip"] ?? '', + "country" => $request["country"] ?? '', + "email" => $request["email"] ?? '', + "gateway" => $request["gateway"] ?? '', + "dids" => json_encode($request["dids"] ?? []), + "call_limit" => $request["call_limit"] ?? 0, + "accountcode" => $request["accountcode"] ?? '', + ); + $this->lastFailedFormAction = "edit_gateway"; + return load_view(__DIR__.'/views/edit_gateway.php',$editVars); + } + } + return load_view(__DIR__.'/views/grid.php',$vars); + } + + } + + /** + * cleanJSLoc + * + * @param string $jsloc + * @return string + */ + public function cleanJSLoc($jsloc){ + $jsloc_array = json_decode($jsloc, true); + + if (isset($jsloc_array['locale_data']['gateway'])) { + foreach ($jsloc_array['locale_data']['gateway'] as $key => &$translation) { + if (is_array($translation)) { + if (empty($translation[0]) && !empty($translation[1])) { + array_shift($translation); + } elseif (count($translation) === 1) { + $translation[0] = $translation[0]; + } + } + } + } + return json_encode($jsloc_array, JSON_UNESCAPED_UNICODE); + } + + /** + * getUsers + * + * @return array + */ + public function getUsers(){ + $allGateway = $this->getAllGateway(); + $sql = "SELECT users.extension, users.name FROM users INNER JOIN sip ON (sip.id = users.extension AND sip.data LIKE 'PJSIP/%' AND users.extension NOT LIKE '99%' AND users.extension NOT LIKE '98%') ORDER BY users.extension;"; + $stm = $this->db->prepare($sql); + $stm->execute(); + $ret = $stm->fetchAll(\PDO::FETCH_ASSOC); + $extGateway = array_column($allGateway, 'extension'); + $result = array_filter($ret, function($item) use ($extGateway) { + return !in_array($item['extension'], $extGateway); + }); + return array_values($result); + } + + private $aorsData = null; + + private function getAorsData() { + if ($this->aorsData === null) { + $response = $this->astman->send_request('Command', ['Command' => 'pjsip show aors']); + $this->aorsData = (is_array($response) && !empty($response['data'])) ? $response['data'] : ''; + } + return $this->aorsData; + } + + public function getEndpointStatus($extension) { + if (!$this->astman) { + return 'unknown'; + } + $data = $this->getAorsData(); + if (empty($data)) { + return 'offline'; + } + $pattern = '/Contact:\s+' . preg_quote($extension, '/') . '\/.*Avail/i'; + return preg_match($pattern, $data) ? 'online' : 'offline'; + } + + public function ajaxRequest($req, &$setting) { + switch ($req) { + case 'gatewayList': + case 'delete': + return true; + default: + return false; + break; + } + } + + public function ajaxHandler(){ + $request = freepbxGetSanitizedRequest(); + switch ($request['command']) { + case 'gatewayList': + $gateways = $this->getAllGateway(); + foreach ($gateways as &$gw) { + $gw['status'] = $this->getEndpointStatus($gw['extension']); + } + return $gateways; + case 'delete': + $result = $this->deleteGateway($request['gateway']); + return $result['status']; + default: + return false; + break; + } + } + + /** + * validate + * + * Validates format/charset of every field AND checks for duplicates + * (Gateway IP, Account Code, DID Base / DIDs) against the other gateways + * already stored, so two gateways can never silently share the same + * accountcode, IP, or DID. + * + * @param array $data the submitted request + * @param string|null $excludeExtension when editing, the extension of the + * gateway being edited, so it isn't + * compared against itself + * @return array + */ + public function validate($data, $excludeExtension = null){ + if( !preg_match('/^\d+$/', trim((string)($data["extension"] ?? '')))){ + return ["status" => "false", "message" => _("The extension is not numeric!")]; + } + + if( !empty($data["contact"]) && !preg_match('/^[a-zA-Z0-9éèêëàâùûüîïôçœæÉÈÊËÀÂÙÛÜÎÏÔÇŒÆ_\s\-]+$/u', trim($data["contact"]))){ + return ["status" => "false", "message" => _("Contact Error: Wrong characters detected!")]; + } + + if( !empty($data["description"]) && !preg_match('/^[a-zA-Z0-9éèêëàâùûüîïôçœæÉÈÊËÀÂÙÛÜÎÏÔÇŒÆ_\s\-]+$/u', trim($data["description"]))){ + return ["status" => "false", "message" => _("Description Error: Wrong characters detected!")]; + } + + if( !empty($data["address"]) && !preg_match('/^[a-zA-Z0-9éèêëàâùûüîïôçœæÉÈÊËÀÂÙÛÜÎÏÔÇŒÆ_\s\-]+$/u', trim($data["address"]))){ + return ["status" => "false", "message" => _("Address Error: Wrong characters detected!")]; + } + + if( !empty($data["city"]) && !preg_match('/^[a-zA-Z0-9éèêëàâùûüîïôçœæÉÈÊËÀÂÙÛÜÎÏÔÇŒÆ_\s\-]+$/u', trim($data["city"]))){ + return ["status" => "false", "message" => _("City Error: Wrong characters detected!")]; + } + + if( !empty($data["country"]) && !preg_match('/^[a-zA-ZéèêëàâùûüîïôçœæÉÈÊËÀÂÙÛÜÎÏÔÇŒÆ_\s\-]+$/u', trim($data["country"]))){ + return ["status" => "false", "message" => _("Country Error: Wrong characters detected!")]; + } + + if( !empty($data["zip"]) && !preg_match('/^\d{3,6}$/', trim($data["zip"]))){ + return ["status" => "false", "message" => _("ZIP Code must contain digits only (3 to 6, column is limited to 6 characters)!")]; + } + + // FILTER_VALIDATE_EMAIL alone accepts RFC5321 quoted local-parts + // (e.g. " diff --git a/views/description.php b/views/description.php new file mode 100644 index 0000000..47d232d --- /dev/null +++ b/views/description.php @@ -0,0 +1,118 @@ +

+
+
+ +
+

+
+
+
+
+
+

+

+

+

+

+
+
+
+
+
+
+
+

+

+

+

+

+ -
+ -
+ -

+

+

+

+

+

+

+

+
+
+
+
+
+
+
+
+
+

+

+ +

+
+
+
+
+
+
+
+
+
+

+

+ +

+
+
+
+
+
+
+
+                        flowchart TD
+                        A[Trunk PJSIP] <--> B(Context:  \n from-trunk-gateway)
+                        B<--> C{Calls}
+                        C<--> D[Context: \n from-internal-gateway\nfa:fa-server\nGW 1]
+                        C<--> E[Context: \n from-internal-gateway\nfa:fa-server\nGW 2]
+                        C<--> F[Context: \n from-internal-gateway\nfa:fa-server\nPBX 1]
+                        D<--> G[fa:fa-phone\nExt 1]
+                        D<--> h[fa:fa-phone\nExt 2]
+                        E<--> i[fa:fa-phone\nExt 1]
+                        E<--> j[fa:fa-phone\nExt 2]
+                        F<--> k[fa:fa-phone\nExt 1]
+                        F<--> L[fa:fa-phone\nExt 2]
+                    
+
+

+

+ from-internal-gateway.
+ from-trunk-gateway.
+

+

+
+
+
+
+
+
+
+ + + + +




+ + + diff --git a/views/edit_gateway.php b/views/edit_gateway.php new file mode 100644 index 0000000..ed381f3 --- /dev/null +++ b/views/edit_gateway.php @@ -0,0 +1,352 @@ +"> +
+ + + + +
+
+
+
+
+
+ + +
+
+ "> +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + +
+
+ "> +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + +
+
+ "> +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + +
+
+ "> +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + +
+
+ "> +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + +
+
+ "> +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + +
+
+ "> +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + +
+
+ "> +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + +
+
+ "> +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + +
+
+ "> +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+
+ + +
+ $did): + if($index === 0) continue; +?> +
+
+
+
+
+
+
+ +
+
+ "> + +
+
+
+
+
+
+
+ +
+ +
+ + diff --git a/views/grid.php b/views/grid.php new file mode 100644 index 0000000..169233a --- /dev/null +++ b/views/grid.php @@ -0,0 +1,42 @@ + +"> + +
+ +
+ + + + + + + + + + + + + + + +
+ + + diff --git a/views/rnav.php b/views/rnav.php new file mode 100644 index 0000000..e5b8eec --- /dev/null +++ b/views/rnav.php @@ -0,0 +1,14 @@ + + +
+ diff --git a/views/toast.php b/views/toast.php new file mode 100644 index 0000000..244b545 --- /dev/null +++ b/views/toast.php @@ -0,0 +1,5 @@ + \ No newline at end of file