├── .gitignore ├── public ├── .user.ini ├── composer.json ├── images │ ├── chandika.jpg │ └── chandika.png ├── manifest.yml ├── autoload.php ├── classes │ ├── migrations │ │ ├── m0004.php │ │ ├── m0003.php │ │ ├── m0011.php │ │ ├── m0008.php │ │ ├── m0009.php │ │ ├── m0005.php │ │ ├── m0002.php │ │ ├── m0010.php │ │ ├── m0007.php │ │ ├── m0006.php │ │ └── m0001.php │ ├── Filter.php │ ├── UserAdministrator.php │ ├── ApiKeyAdministrator.php │ ├── BillingAdministrator.php │ ├── CrudHelper.php │ ├── DB.php │ ├── AccountAdministrator.php │ ├── Authenticator.php │ ├── ServiceAdministrator.php │ └── ResourceAdministrator.php ├── add_administrator.php ├── add_resource.php ├── api │ ├── billing_month.php │ ├── billing.php │ └── account.php ├── login.php ├── show_billing_service.php ├── edit_service.php ├── edit_account.php ├── show_administrators.php ├── show_accounts.php ├── show_api_keys.php ├── show_billing_month.php ├── show_billing.php ├── show_resources.php ├── index.php ├── show_services.php ├── css │ ├── style.css │ └── jquery-ui.css ├── header.php ├── composer.lock └── js │ └── bootstrap.min.js ├── circle.yml ├── Vagrantfile ├── scripts ├── billing_tagnotes.py └── billing.py ├── CONTRIBUTING.md ├── LICENSE.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | .vagrant -------------------------------------------------------------------------------- /public/.user.ini: -------------------------------------------------------------------------------- 1 | short_open_tag=On 2 | -------------------------------------------------------------------------------- /public/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "guzzlehttp/guzzle": "6.*" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/images/chandika.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/18F/chandika/HEAD/public/images/chandika.jpg -------------------------------------------------------------------------------- /public/images/chandika.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/18F/chandika/HEAD/public/images/chandika.png -------------------------------------------------------------------------------- /public/manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | applications: 3 | - name: chandika 4 | memory: 256M 5 | buildpack: https://github.com/heroku/heroku-buildpack-php 6 | -------------------------------------------------------------------------------- /public/autoload.php: -------------------------------------------------------------------------------- 1 | exec("ALTER TABLE services ADD COLUMN tag VARCHAR(255) NULL"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /public/classes/migrations/m0003.php: -------------------------------------------------------------------------------- 1 | exec("ALTER TABLE resources MODIFY uri VARCHAR(255) NOT NULL"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /public/classes/migrations/m0011.php: -------------------------------------------------------------------------------- 1 | exec("ALTER TABLE accounts MODIFY label VARCHAR(127) NOT NULL"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /public/classes/migrations/m0008.php: -------------------------------------------------------------------------------- 1 | exec("ALTER TABLE billing ADD COLUMN discount_factor DECIMAL(10,8) NOT NULL DEFAULT 1"); 8 | } 9 | } -------------------------------------------------------------------------------- /public/classes/migrations/m0009.php: -------------------------------------------------------------------------------- 1 | exec("ALTER TABLE accounts ADD COLUMN is_archived TINYINT NOT NULL DEFAULT 0"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /public/classes/migrations/m0005.php: -------------------------------------------------------------------------------- 1 | exec("ALTER TABLE services ADD COLUMN billing_code VARCHAR(255) NOT NULL DEFAULT ''"); 8 | $conn->exec("ALTER TABLE services DROP COLUMN is_billable"); 9 | } 10 | } -------------------------------------------------------------------------------- /public/classes/migrations/m0002.php: -------------------------------------------------------------------------------- 1 | exec("ALTER TABLE accounts ADD COLUMN email VARCHAR(255) NOT NULL DEFAULT ''"); 8 | $conn->exec("ALTER TABLE services ADD COLUMN is_billable TINYINT NOT NULL DEFAULT 0"); 9 | } 10 | } -------------------------------------------------------------------------------- /public/classes/migrations/m0010.php: -------------------------------------------------------------------------------- 1 | exec("ALTER TABLE billing MODIFY tagname VARCHAR(127)"); 8 | $conn->exec("ALTER TABLE billing MODIFY tagvalue VARCHAR(127)"); 9 | $conn->exec("ALTER TABLE billing ADD COLUMN tagnote VARCHAR(127) NULL"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /public/classes/Filter.php: -------------------------------------------------------------------------------- 1 | "; 6 | foreach ($values as $key => $label) { 7 | $selected_text = $key == $selected ? " selected" : ""; 8 | $dropdown .= ""; 9 | } 10 | return $dropdown.""; 11 | } 12 | } 13 | ?> -------------------------------------------------------------------------------- /public/add_administrator.php: -------------------------------------------------------------------------------- 1 | assertRole(Authenticator::administrator); 5 | 6 | $users = new UserAdministrator(); 7 | switch ($_REQUEST["action"]) { 8 | case "CREATE": 9 | $email = $_REQUEST["email"]; 10 | $users->create($email); 11 | break; 12 | case "DELETE": 13 | $id = $_REQUEST["id"]; 14 | $users->delete($id); 15 | } 16 | header("Location: /show_administrators.php"); 17 | ?> 18 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | pre: 3 | - mkdir -p debs 4 | - if [ ! -f debs/temp.deb ]; then wget -qO debs/temp.deb https://cli.run.pivotal.io/stable?release=debian64; fi 5 | - sudo dpkg -i debs/temp.deb 6 | cache_directories: 7 | - debs 8 | 9 | deployment: 10 | production: 11 | branch: [master] 12 | commands: 13 | - cf login -a https://api.fr.cloud.gov/ -u $CF_CHANDIKA_USER -p $CF_CHANDIKA_PASS -o gsa-tts-infrastructure -s chandika-prod 14 | - cd public && cf push chandika 15 | -------------------------------------------------------------------------------- /public/add_resource.php: -------------------------------------------------------------------------------- 1 | create($resource_type, $creator, $uri, strtotime($_REQUEST["expiry_date"])); 13 | } 14 | if ($_REQUEST["action"] == "DELETE") { 15 | ResourceAdministrator::delete($_REQUEST["id"]); 16 | } 17 | 18 | header("Location: /show_resources.php?service_id=$service_id"); 19 | ?> 20 | -------------------------------------------------------------------------------- /public/classes/migrations/m0007.php: -------------------------------------------------------------------------------- 1 | exec("CREATE TABLE IF NOT EXISTS api_keys ( 8 | id INT NOT NULL AUTO_INCREMENT, 9 | label VARCHAR(50) NOT NULL, 10 | last_used DATETIME NULL, 11 | active TINYINT NOT NULL DEFAULT 1, 12 | uuid VARCHAR(50) NOT NULL, 13 | PRIMARY KEY(id))"); 14 | $conn->exec("ALTER TABLE services ADD COLUMN is_archived TINYINT NOT NULL DEFAULT 0"); 15 | $conn->exec("ALTER TABLE services ADD COLUMN description VARCHAR(255) NULL"); 16 | } 17 | } -------------------------------------------------------------------------------- /public/api/billing_month.php: -------------------------------------------------------------------------------- 1 | prepare($sql); 19 | foreach ($tag_notes as $tagvalue => $tagnote) { 20 | $statement->execute([":date" => $month, ":tagvalue" => $tagvalue, ":tagnote" => $tagnote]); 21 | } 22 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure("2") do |config| 5 | 6 | config.vm.box = "scotch/box" 7 | config.vm.network "private_network", ip: "192.168.33.20" 8 | config.vm.hostname = "scotchbox" 9 | config.vm.synced_folder ".", "/var/www", :mount_options => ["dmode=777", "fmode=666"] 10 | config.vm.provider "virtualbox" do |v| 11 | v.memory = 1024 12 | v.cpus = 1 13 | end 14 | 15 | # Optional NFS. Make sure to remove other synced_folder line too 16 | #config.vm.synced_folder ".", "/var/www", :nfs => { :mount_options => ["dmode=777","fmode=666"] } 17 | 18 | config.vm.provision "shell", inline: <<-SHELL 19 | sudo bash -c \'echo export CHANDIKA_OAUTH="OFF" >> /etc/apache2/envvars\' 20 | 21 | sudo service apache2 restart 22 | SHELL 23 | end 24 | -------------------------------------------------------------------------------- /public/classes/migrations/m0006.php: -------------------------------------------------------------------------------- 1 | exec("ALTER TABLE accounts CHANGE COLUMN nickname label VARCHAR(50) NOT NULL"); 8 | $conn->exec("ALTER TABLE accounts ADD COLUMN description VARCHAR(255) NULL"); 9 | $conn->exec("CREATE TABLE IF NOT EXISTS billing ( 10 | id INT NOT NULL AUTO_INCREMENT, 11 | provider VARCHAR(50) NOT NULL, 12 | invoice_date DATE NOT NULL, 13 | identifier VARCHAR(255) NOT NULL, 14 | tagname VARCHAR(50) NULL, 15 | tagvalue VARCHAR(50) NULL, 16 | amount DECIMAL (10,2), 17 | PRIMARY KEY(id))"); 18 | } 19 | } -------------------------------------------------------------------------------- /public/classes/UserAdministrator.php: -------------------------------------------------------------------------------- 1 | query($sql, PDO::FETCH_OBJ) as $row) { 9 | $results[] = $row; 10 | } 11 | return $results; 12 | } 13 | 14 | public function create($email) 15 | { 16 | $insert = DB::connection()->prepare("INSERT INTO administrators (email) VALUES (:email)"); 17 | $insert->bindParam(':email', $email); 18 | $insert->execute(); 19 | } 20 | 21 | public function delete($id) 22 | { 23 | $delete = DB::connection()->prepare("DELETE FROM administrators WHERE id = :id"); 24 | $delete->bindParam(':id', $id); 25 | $delete->execute(); 26 | } 27 | } 28 | ?> -------------------------------------------------------------------------------- /public/login.php: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | Chandika 11 | 12 | 13 | 14 | 15 | 16 |
17 |

Log in to Chandika

18 |

19 |

" class="btn-social">Sign in with GitHub

20 |
21 | 22 | -------------------------------------------------------------------------------- /public/show_billing_service.php: -------------------------------------------------------------------------------- 1 | service($service_id); 9 | include "header.php"; 10 | ?> 11 |
12 |

Billing for service 'name ?>'

13 | tag)) { 15 | print "This service has no infrastructure tag associated with it."; 16 | } else { 17 | 18 | $billing_data = BillingAdministrator::byService($service->tag); 19 | 20 | ?> 21 | Aggregating billing data for all tags with value tag ?>.

22 | 23 | 24 | 25 | 26 | 27 | "; 30 | } 31 | ?> 32 |
Invoice dateAmount
{$line->invoice_date}" . money_format('%(#10n', $line->total) . "
33 |
34 | 37 | 38 | -------------------------------------------------------------------------------- /public/edit_service.php: -------------------------------------------------------------------------------- 1 | update($_REQUEST["service_id"], $_REQUEST); 10 | break; 11 | case "CREATE": 12 | $sa->create($_REQUEST); 13 | break; 14 | } 15 | header("Location: /show_services.php"); 16 | die(); 17 | } 18 | 19 | $service_id = $_REQUEST["service_id"]; 20 | $accounts = []; 21 | @array_walk(AccountAdministrator::accounts(), function ($value, $key) use (&$accounts) { 22 | $accounts[$value->id] = $value->label; 23 | }); 24 | 25 | $service = $sa->service($service_id); 26 | 27 | include "header.php"; 28 | ?> 29 |
30 |

Edit system

31 |
32 | 33 | $accounts], $service); ?> 34 | 35 |
36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /scripts/billing_tagnotes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import argparse 4 | import csv 5 | import re 6 | import json 7 | import http.client 8 | 9 | parser = argparse.ArgumentParser(description='Update AWS billing data by tag and date.') 10 | parser.add_argument('--chandika', dest='chandika', help="Chandika hostname") 11 | parser.add_argument('--api-key', dest='api_key', help="Chandika API key") 12 | parser.add_argument('--invoice-date', dest='invoice_date', help="Invoice date") 13 | parser.add_argument('billing_notes_csv', help="Billing Notes CSV file") 14 | args = parser.parse_args() 15 | 16 | tag_notes = {} 17 | statement = 0 18 | month = '' 19 | 20 | with open(args.billing_notes_csv) as csvfile: 21 | reader = csv.DictReader(csvfile) 22 | for row in reader: 23 | tag_notes[row['TagValue']] = row['TagNotes'] 24 | 25 | invoice_date = args.invoice_date if args.invoice_date else month 26 | output = { 'provider' : 'Amazon AWS', 'invoice_date' : invoice_date, 'tag_notes' : tag_notes } 27 | 28 | if args.chandika: 29 | conn = http.client.HTTPSConnection(args.chandika, timeout=2) 30 | conn.request("POST", "/api/billing_month.php?api_key=" + args.api_key, body=json.dumps(output)) 31 | else: 32 | print(json.dumps(output)) 33 | -------------------------------------------------------------------------------- /public/edit_account.php: -------------------------------------------------------------------------------- 1 | assertRole(Authenticator::administrator); 5 | 6 | if (key_exists("action", $_REQUEST)) { 7 | switch ($_REQUEST["action"]) { 8 | case "UPDATE": 9 | AccountAdministrator::update($_REQUEST["account_id"], $_REQUEST); 10 | break; 11 | case "DELETE": 12 | AccountAdministrator::delete($_REQUEST["account_id"]); 13 | break; 14 | case "CREATE": 15 | AccountAdministrator::create($_REQUEST); 16 | break; 17 | } 18 | header("Location: /show_accounts.php"); 19 | die(); 20 | } 21 | $account_id = $_REQUEST["account_id"]; 22 | $account = AccountAdministrator::account($account_id); 23 | $checked = $account->is_prod == 1 ? " checked" : ""; 24 | 25 | include "header.php"; 26 | ?> 27 |
28 |

Edit account

29 |
30 | 31 | AccountAdministrator::providers()], $account) ?> 32 | 33 |
34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Welcome! 2 | 3 | We're so glad you're thinking about contributing to an 18F open source project! If you're unsure about anything, just ask -- or submit the issue or pull request anyway. The worst that can happen is you'll be politely asked to change something. We love all friendly contributions. 4 | 5 | We want to ensure a welcoming environment for all of our projects. Our staff follow the [18F Code of Conduct](https://github.com/18F/code-of-conduct/blob/master/code-of-conduct.md) and all contributors should do the same. 6 | 7 | We encourage you to read this project's CONTRIBUTING policy (you are here), its [LICENSE](LICENSE.md), and its [README](README.md). 8 | 9 | If you have any questions or want to read more, check out the [18F Open Source Policy GitHub repository]( https://github.com/18f/open-source-policy), or just [shoot us an email](mailto:18f@gsa.gov). 10 | 11 | ## Public domain 12 | 13 | This project is in the public domain within the United States, and 14 | copyright and related rights in the work worldwide are waived through 15 | the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/). 16 | 17 | All contributions to this project will be released under the CC0 18 | dedication. By submitting a pull request, you are agreeing to comply 19 | with this waiver of copyright interest. 20 | -------------------------------------------------------------------------------- /public/classes/ApiKeyAdministrator.php: -------------------------------------------------------------------------------- 1 | $uuid, ":label" => $label]); 21 | return $uuid; 22 | } 23 | 24 | public static function delete($id) 25 | { 26 | DB::execute("DELETE FROM api_keys WHERE id = :id", [":id" => $id]); 27 | } 28 | 29 | public static function authenticate() 30 | { 31 | if (!key_exists("api_key", $_REQUEST)) { 32 | die(); 33 | } 34 | $sql = "UPDATE api_keys SET last_used = NOW() WHERE uuid = :uuid"; 35 | $stmt = DB::connection()->prepare($sql); 36 | $stmt->execute([":uuid" => $_REQUEST["api_key"]]); 37 | if ($stmt->rowCount() != 1) { 38 | die(); 39 | } 40 | } 41 | } 42 | ?> -------------------------------------------------------------------------------- /public/show_administrators.php: -------------------------------------------------------------------------------- 1 | assertRole(Authenticator::administrator); 5 | 6 | if (isset($_REQUEST["action"]) && $_REQUEST["action"] == "Migrate DB") { 7 | DB::migrate(); 8 | } 9 | 10 | include "header.php"; 11 | ?> 12 |
13 |

Administrators

14 | 15 | 16 | 17 | 18 | 19 | users() as $row) { 22 | print " 23 | "; 24 | } 25 | ?> 26 |
EmailActions
{$row->email}Delete
27 |
28 |

Add administrator

29 |
30 | 31 |
32 | 33 |
34 |
35 |

Migrate DB

36 |

Run this after upgrading Chandika

37 |
38 | 39 |
40 | 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Public Domain 2 | 3 | As a work of the United States Government, this project is in the 4 | public domain within the United States. 5 | 6 | Additionally, we waive copyright and related rights in the work 7 | worldwide through the CC0 1.0 Universal public domain dedication. 8 | 9 | ## CC0 1.0 Universal Summary 10 | 11 | This is a human-readable summary of the [Legal Code (read the full text)](https://creativecommons.org/publicdomain/zero/1.0/legalcode). 12 | 13 | ### No Copyright 14 | 15 | The person who associated a work with this deed has dedicated the work to 16 | the public domain by waiving all of his or her rights to the work worldwide 17 | under copyright law, including all related and neighboring rights, to the 18 | extent allowed by law. 19 | 20 | You can copy, modify, distribute and perform the work, even for commercial 21 | purposes, all without asking permission. 22 | 23 | ### Other Information 24 | 25 | In no way are the patent or trademark rights of any person affected by CC0, 26 | nor are the rights that other persons may have in the work or in how the 27 | work is used, such as publicity or privacy rights. 28 | 29 | Unless expressly stated otherwise, the person who associated a work with 30 | this deed makes no warranties about the work, and disclaims liability for 31 | all uses of the work, to the fullest extent permitted by applicable law. 32 | When using or citing the work, you should not imply endorsement by the 33 | author or the affirmer. 34 | -------------------------------------------------------------------------------- /public/show_accounts.php: -------------------------------------------------------------------------------- 1 | assertRole(Authenticator::administrator); 5 | 6 | include "header.php"; 7 | ?> 8 |
9 |

Accounts

10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | provider]; 24 | $prod = $row->is_prod ? "Yes" : "No"; 25 | $archived = $row->is_archived ? "Yes" : "No"; 26 | print ""; 27 | } 28 | ?> 29 |
LabelProviderIdentifierNotification emailDescriptionProductionArchivedActions
{$row->label}$provider{$row->identifier}{$row->email}{$row->description}$prod$archivedEdit | Delete
30 |
31 |

Add account

32 |
33 | AccountAdministrator::providers()], []) ?> 34 | 35 |
36 |
37 | 38 | -------------------------------------------------------------------------------- /public/show_api_keys.php: -------------------------------------------------------------------------------- 1 | assertRole(Authenticator::administrator); 5 | 6 | if (key_exists("action", $_REQUEST)) { 7 | switch ($_REQUEST["action"]) { 8 | case "CREATE": 9 | ApiKeyAdministrator::create($_REQUEST["label"]); 10 | break; 11 | case "DELETE": 12 | ApiKeyAdministrator::delete($_REQUEST["id"]); 13 | break; 14 | } 15 | } 16 | 17 | include "header.php"; 18 | ?> 19 |
20 |

API keys

21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | last_used == null ? "Never" : $row->last_used; 32 | print " 33 | 34 | "; 35 | } 36 | ?> 37 |
API keyLabelLast usedActions
{$row->uuid}{$row->label}$last_usedDelete
38 |
39 |

Add API key

40 |
41 | 42 | 43 | 44 |
45 |
46 | 47 | -------------------------------------------------------------------------------- /public/show_billing_month.php: -------------------------------------------------------------------------------- 1 | tagname] = $value->tagname; 11 | }); 12 | 13 | $selected_tag = key_exists("tag_name", $_REQUEST) ? $_REQUEST["tag_name"] : array_keys($tag_names)[0]; 14 | 15 | $billing_data = BillingAdministrator::byTag($account_id, $invoice_date, $selected_tag); 16 | 17 | include "header.php"; 18 | ?> 19 |
20 |

Billing by tag

21 |
22 | Account: 23 | Invoice date: 24 | Available tags: 25 | 26 | 27 | 28 |

29 | 30 | 31 | 32 | 33 | 34 | 35 | "; 38 | } 39 | ?> 40 |
Tag valueAmountTag note
{$account->tagvalue}".money_format('%(#10n', $account->total)."{$account->tagnote}
41 |
42 | 43 | -------------------------------------------------------------------------------- /public/api/billing.php: -------------------------------------------------------------------------------- 1 | prepare("DELETE FROM billing WHERE invoice_date = :date AND provider = :provider"); 19 | $statement->execute([":date" => $month, ":provider" => $provider]); 20 | $statement = DB::connection()->prepare("INSERT INTO billing (provider, invoice_date, identifier, discount_factor, tagname, tagvalue, amount) 21 | VALUES (:provider, :invoice_date, :identifier, :discount_factor, :tagname, :tagvalue, :amount)"); 22 | foreach ($costs as $account => $tagged) { 23 | foreach ($tagged as $tag_name => $values) { 24 | foreach ($values as $tag_value => $amount) { 25 | $statement->execute([":invoice_date" => $month, ":provider" => $provider, ":identifier" => $account, ":discount_factor" => $discount_factor, 26 | ":tagname" => $tag_name, ":tagvalue" => $tag_value, ":amount" => round($amount, 2)]); 27 | } 28 | } 29 | } 30 | 31 | foreach ($totals as $account => $amount) { 32 | $statement->execute([":invoice_date" => $month, ":provider" => $provider, ":identifier" => $account, ":discount_factor" => $discount_factor, 33 | ":tagname" => null, ":tagvalue" => null, ":amount" => round($amount, 2)]); 34 | } 35 | -------------------------------------------------------------------------------- /public/classes/BillingAdministrator.php: -------------------------------------------------------------------------------- 1 | $date]); 16 | } 17 | 18 | public static function byTag($account_id, $invoice_date, $tag_name) 19 | { 20 | $query = "SELECT SUM(amount) * discount_factor as total, tagvalue, tagnote FROM billing 21 | WHERE tagname = :tagname AND invoice_date = :invoice_date AND identifier = :account_id 22 | GROUP BY tagvalue ORDER BY tagvalue"; 23 | return DB::query($query, [":invoice_date" => $invoice_date, ":account_id" => $account_id, ":tagname" => $tag_name]); 24 | } 25 | 26 | public static function tags($account_id, $invoice_date) 27 | { 28 | $query = "SELECT DISTINCT tagname FROM billing WHERE invoice_date = :invoice_date AND identifier = :identifier AND tagname IS NOT NULL ORDER BY tagname"; 29 | return DB::query($query, [":identifier" => $account_id, ":invoice_date" => $invoice_date]); 30 | } 31 | 32 | public static function byService($tag_value) 33 | { 34 | $query = "SELECT SUM(amount) * discount_factor as total, invoice_date FROM billing WHERE tagvalue = :tagvalue GROUP BY invoice_date ORDER BY invoice_date DESC"; 35 | return DB::query($query, [":tagvalue" => $tag_value]); 36 | } 37 | } -------------------------------------------------------------------------------- /public/show_billing.php: -------------------------------------------------------------------------------- 1 | invoice_date] = $value->invoice_date; 8 | }); 9 | if (count($months) > 0) { 10 | $selected_date = key_exists("month", $_REQUEST) ? $_REQUEST["month"] : array_keys($months)[0]; 11 | $billing_data = BillingAdministrator::byInvoiceDate($selected_date); 12 | } 13 | 14 | include "header.php"; 15 | ?> 16 |
17 |

Billing

18 | 19 | Chandika has no billing data available. 20 | To load billing data into Chandika from Amazon AWS, set up "Detailed Billing Report with Resources and Tags" 21 | as described here. 22 | Once you have downloaded the csv file, you can import it into Chandika 23 | using this script. 24 | 25 |
26 | Available invoice dates: 27 | 28 |
29 |

Total spend for invoice date:

30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | "; 40 | } 41 | ?> 42 |
Account idLabelAmountDescription
{$account->identifier}{$account->label}".money_format('%(#10n', $account->amount)."{$account->description}
43 | 44 |
45 | 46 | -------------------------------------------------------------------------------- /public/show_resources.php: -------------------------------------------------------------------------------- 1 | 9 |
10 |

Resources for service name() ?>

11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | resources() as $row) { 22 | $expiry = gmdate("Y-m-d", $row->expires); 23 | print ""; 24 | } 25 | ?> 26 |
TypeOwnerURICreatedExpiresAction
{$row->resource_type}{$row->owner}{$row->uri}{$row->created}$expiryDelete
27 |
28 |

Add resource

29 |

For AWS resources, please use the ARN as the URL. 31 | The account id for this service is 'account_identifier() ?>'

32 |
33 | 34 | 35 |
39 |
40 |
41 | 42 |
43 |
44 | 45 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | "all"]; 11 | foreach ($service_admin->services($auth) as $service) { 12 | $services[$service->id] = $service->name; 13 | } 14 | $resource_types = ["" => "all"]; 15 | foreach (ResourceAdministrator::types() as $resource) { 16 | $resource_types[$resource] = $resource; 17 | } 18 | ?> 19 |
20 |

Resources

21 |
22 | Filter by: 23 | expiry date: "7 days", "30" => "30 days", "365" => "1 year", "" => "all"], $expiry_selected)?> 24 | service: 25 | resource type: 26 | 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | expires === null ? "None" : gmdate("Y-m-d", $row->expires); 39 | print ""; 40 | } 41 | ?> 42 |
ServiceAccountResource typeURIExpiry date
{$row->service_name}{$row->account_label}{$row->resource_type}{$row->resource_uri}$expiry
43 |
44 | 45 | -------------------------------------------------------------------------------- /public/classes/migrations/m0001.php: -------------------------------------------------------------------------------- 1 | exec("CREATE TABLE IF NOT EXISTS administrators ( 8 | id INT NOT NULL AUTO_INCREMENT, 9 | email VARCHAR(50) NOT NULL, 10 | created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 11 | PRIMARY KEY(id))"); 12 | $conn->exec("CREATE TABLE IF NOT EXISTS accounts ( 13 | id INT NOT NULL AUTO_INCREMENT, 14 | nickname VARCHAR(50) NOT NULL, 15 | provider VARCHAR(50) NOT NULL, 16 | identifier VARCHAR(255) NOT NULL, 17 | is_prod TINYINT NOT NULL DEFAULT 0, 18 | created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 19 | PRIMARY KEY(id))"); 20 | $conn->exec("CREATE TABLE IF NOT EXISTS services ( 21 | id INT NOT NULL AUTO_INCREMENT, 22 | name VARCHAR(255) NOT NULL, 23 | account_id INT NOT NULL, 24 | repository VARCHAR(255) NOT NULL, 25 | url VARCHAR(255) NOT NULL, 26 | owner VARCHAR(255) NOT NULL, 27 | verified INT NULL, 28 | created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 29 | PRIMARY KEY(id))"); 30 | $conn->exec("CREATE TABLE IF NOT EXISTS resources ( 31 | id INT NOT NULL AUTO_INCREMENT, 32 | service_id INT NOT NULL, 33 | resource_type VARCHAR(50) NOT NULL, 34 | owner VARCHAR(255) NOT NULL, 35 | uri VARCHAR(50) NOT NULL, 36 | created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 37 | expires INT NULL, 38 | PRIMARY KEY(id))"); 39 | } 40 | } -------------------------------------------------------------------------------- /public/api/account.php: -------------------------------------------------------------------------------- 1 | prepare("SELECT label, is_prod, provider FROM accounts WHERE identifier = :account"); 10 | $statement->execute([":account" => $account_id]); 11 | $row = $statement->fetch(PDO::FETCH_OBJ); 12 | $account["Name"] = $row->label; 13 | $account["Production"] = $row->is_prod ? "1" : "0"; 14 | $account["Provider"] = AccountAdministrator::providers()[$row->provider]; 15 | 16 | $statement = DB::connection()->prepare("SELECT s.tag, s.id, s.name, s.repository FROM services s JOIN accounts a ON s.account_id = a.id 17 | WHERE a.identifier = :account"); 18 | $statement->execute([":account" => $account_id]); 19 | $service_info = []; 20 | while ($row = $statement->fetch(PDO::FETCH_OBJ)) { 21 | $service_info[$row->id] = $row; 22 | } 23 | 24 | $statement = DB::connection()->prepare("SELECT r.uri, r.resource_type, s.id FROM services s JOIN accounts a ON s.account_id = a.id JOIN resources r ON r.service_id = s.id 25 | WHERE a.identifier = :account AND (expires IS NULL OR expires > UNIX_TIMESTAMP()) ORDER BY s.id, r.resource_type"); 26 | $statement->execute([":account" => $account_id]); 27 | $services = []; 28 | $service_id = 0; 29 | 30 | while ($row = $statement->fetch(PDO::FETCH_OBJ)) { 31 | if ($row->id != $service_id) { 32 | if ($service_id != 0) { 33 | $services[$service_id] = $resources; 34 | } 35 | $service_id = $row->id; 36 | $resources = []; 37 | } 38 | if (empty($resources[$row->resource_type])) { 39 | $resources[$row->resource_type] = []; 40 | } 41 | $resources[$row->resource_type][] = $row->uri; 42 | } 43 | if ($statement->rowCount() > 0) { 44 | $services[$service_id] = $resources; 45 | } 46 | $service_json = []; 47 | foreach ($service_info as $service_id => $info) { 48 | $resources = !array_key_exists($service_id, $services) || empty($services[$service_id]) ? [] : $services[$service_id]; 49 | $service_json[] = [ "Name" => $info->name, "Tag" => $info->tag, "Repository" => $info->repository , "Resources" => $resources ]; 50 | } 51 | $account["Systems"] = $service_json; 52 | print json_encode($account); 53 | ?> 54 | -------------------------------------------------------------------------------- /public/classes/CrudHelper.php: -------------------------------------------------------------------------------- 1 | fields = $fields; 14 | } 15 | 16 | public function bind(&$statement, &$properties) 17 | { 18 | foreach ($this->fields as $field) { 19 | $name = $field["name"]; 20 | if (key_exists("type", $field)) { 21 | switch ($field["type"]) { 22 | case self::checkbox: 23 | $value = isset($properties[$name]) ? 1 : 0; 24 | break; 25 | default: 26 | $value = $properties[$name]; 27 | break; 28 | } 29 | } else { 30 | $value = $properties[$name]; 31 | } 32 | $statement->bindValue(":$name", $value); 33 | } 34 | } 35 | 36 | public function form($options, $selected) 37 | { 38 | $output = ""; 39 | $values = (array) $selected; 40 | foreach ($this->fields as $field) { 41 | $name = $field["name"]; 42 | $value = key_exists($name, $values) ? $values[$name] : ""; 43 | if (key_exists("type", $field)) { 44 | switch ($field["type"]) { 45 | case self::checkbox: 46 | $checked = (key_exists($name, $values) && $values[$name] == 1) ? " checked" : ""; 47 | $output .= " {$field["description"]}
"; 48 | break; 49 | case self::dropdown: 50 | $option = key_exists($name, $values) ? $values[$name] : ""; 51 | $output .= " ".Filter::dropdown($name, $options[$name], $option)."
"; 52 | break; 53 | } 54 | } else { 55 | $output .= "
"; 56 | } 57 | } 58 | return $output; 59 | } 60 | } -------------------------------------------------------------------------------- /scripts/billing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import argparse 4 | import csv 5 | import re 6 | import json 7 | import http.client 8 | 9 | parser = argparse.ArgumentParser(description='Aggregate AWS billing data by account and tag.') 10 | parser.add_argument('--chandika', dest='chandika', help="Chandika hostname") 11 | parser.add_argument('--api-key', dest='api_key', help="Chandika API key") 12 | parser.add_argument('--invoice-date', dest='invoice_date', help="Invoice date") 13 | parser.add_argument('--discount-factor', dest='discount_factor', help="If your vendor adds a discount, put it here. 1 = full price.") 14 | parser.add_argument('billing_csv', help="Billing CSV file") 15 | parser.add_argument('tag_name', nargs='*', help="Names of tags to aggregate by") 16 | args = parser.parse_args() 17 | 18 | costs = {} 19 | totals = {} 20 | statement = 0 21 | month = '' 22 | no_tags = len(args.tag_name) == 0 23 | 24 | with open(args.billing_csv) as csvfile: 25 | reader = csv.DictReader(csvfile) 26 | for row in reader: 27 | if row['RecordType'] == 'AccountTotal': 28 | totals[row['LinkedAccountId']] = row['BlendedCost'] 29 | elif row['RecordType'] == 'StatementTotal': 30 | statement = row['BlendedCost'] 31 | match = re.search('\d\d\d\d-\d\d-\d\d', row['ItemDescription']) 32 | month = match.group() 33 | elif row['RecordType'] == 'LineItem': 34 | if row['LinkedAccountId'] not in costs: 35 | costs[row['LinkedAccountId']] = { '' : { '' : 0 } } if no_tags else {} 36 | for tag in args.tag_name: 37 | costs[row['LinkedAccountId']][tag] = {} 38 | for tag in args.tag_name: 39 | if row['user:' + tag] not in costs[row['LinkedAccountId']][tag]: 40 | costs[row['LinkedAccountId']][tag][row['user:' + tag]] = 0 41 | costs[row['LinkedAccountId']][tag][row['user:' + tag]] = costs[row['LinkedAccountId']][tag][row['user:' + tag]] + float(row['UnBlendedCost']) 42 | if no_tags: 43 | costs[row['LinkedAccountId']][''][''] = costs[row['LinkedAccountId']][''][''] + float(row['UnBlendedCost']) 44 | 45 | invoice_date = args.invoice_date if args.invoice_date else month 46 | discount_factor = args.discount_factor if args.discount_factor else 1 47 | output = { 'provider' : 'Amazon AWS', 'invoice_date' : invoice_date, 'discount_factor' : discount_factor, 'costs' : costs, 'totals' : totals, 'statement' : statement } 48 | 49 | if args.chandika: 50 | conn = http.client.HTTPSConnection(args.chandika, timeout=2) 51 | conn.request("POST", "/api/billing.php?api_key=" + args.api_key, body=json.dumps(output)) 52 | else: 53 | print(json.dumps(output)) 54 | -------------------------------------------------------------------------------- /public/classes/DB.php: -------------------------------------------------------------------------------- 1 | setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 18 | $stmt = self::$conn->query("SHOW TABLES LIKE 'migrations'"); 19 | if ($stmt->rowCount() == 0) self::migrate(); 20 | } 21 | return self::$conn; 22 | } 23 | 24 | public static function migrate() { 25 | self::$conn->exec("CREATE TABLE IF NOT EXISTS migrations ( 26 | id INT NOT NULL AUTO_INCREMENT, 27 | migration VARCHAR(10) NOT NULL, 28 | PRIMARY KEY(id))"); 29 | $sql = "SELECT migration FROM migrations ORDER BY id DESC"; 30 | $migrated = []; 31 | foreach (self::$conn->query($sql, PDO::FETCH_OBJ) as $row) { 32 | $migrated[] = $row->migration; 33 | } 34 | $migrations = scandir($_SERVER["DOCUMENT_ROOT"]."/classes/migrations"); 35 | foreach ($migrations as $migration) { 36 | if (substr($migration, 0, 1) == "m" && !in_array($migration, $migrated)) { 37 | $classname = substr($migration, 0, -4); 38 | include "migrations/$migration"; 39 | $migrate = new $classname; 40 | $migrate->migrate(self::$conn); 41 | self::$conn->exec("INSERT INTO migrations (migration) VALUES ('$migration')"); 42 | } 43 | } 44 | } 45 | 46 | public static function query($query, $params = []) { 47 | $results = []; 48 | $stmt = self::$conn->prepare($query); 49 | $stmt->execute($params); 50 | while ($row = $stmt->fetch(PDO::FETCH_OBJ)) { 51 | $results[] = $row; 52 | } 53 | return $results; 54 | } 55 | 56 | public static function execute($query, $params = []) { 57 | $stmt = self::$conn->prepare($query); 58 | $stmt->execute($params); 59 | } 60 | } 61 | ?> 62 | -------------------------------------------------------------------------------- /public/classes/AccountAdministrator.php: -------------------------------------------------------------------------------- 1 | "Amazon AWS" ]; 7 | } 8 | 9 | public static function accounts() 10 | { 11 | return DB::query("SELECT id, label, provider, identifier, description, email, is_prod, is_archived FROM accounts ORDER BY label"); 12 | } 13 | 14 | public static function account($id) { 15 | foreach (self::accounts() as $account) { 16 | if ($account->id == $id) return $account; 17 | } 18 | return null; 19 | } 20 | 21 | public static function create($properties) 22 | { 23 | $insert = DB::connection()->prepare("INSERT INTO accounts (label, provider, identifier, email, description, is_prod, is_archived) VALUES (:label, :provider, :identifier, :email, :description, :is_prod, :is_archived)"); 24 | self::helper()->bind($insert, $properties); 25 | $insert->execute(); 26 | } 27 | 28 | public static function update($id, $properties) 29 | { 30 | $update = DB::connection()->prepare("UPDATE accounts SET label = :label, provider = :provider, identifier = :identifier, email = :email, description = :description, is_prod = :is_prod, is_archived = :is_archived WHERE id = :id"); 31 | self::helper()->bind($update, $properties); 32 | $update->bindParam(":id", $id); 33 | $update->execute(); 34 | } 35 | 36 | private static function helper() 37 | { 38 | $fields = [ 39 | [CrudHelper::name => "label", CrudHelper::desc => "Label"], 40 | [CrudHelper::name => "provider", CrudHelper::desc => "Provider", CrudHelper::type => CrudHelper::dropdown], 41 | [CrudHelper::name => "identifier", CrudHelper::desc => "Identifier"], 42 | [CrudHelper::name => "description", CrudHelper::desc => "Description"], 43 | [CrudHelper::name => "email", CrudHelper::desc => "Email"], 44 | [CrudHelper::name => "is_prod", CrudHelper::desc => "Production account", CrudHelper::type => CrudHelper::checkbox], 45 | [CrudHelper::name => "is_archived", CrudHelper::desc => "Archived account", CrudHelper::type => CrudHelper::checkbox] 46 | ]; 47 | return new CrudHelper($fields); 48 | } 49 | 50 | public static function form($options, $selected) 51 | { 52 | return self::helper()->form($options, $selected); 53 | } 54 | 55 | public static function delete($account_id) 56 | { 57 | $services = DB::query("SELECT id FROM services WHERE account_id = :account_id", [":account_id" => $account_id]); 58 | if (count($services) == 0) { 59 | DB::execute("DELETE FROM accounts WHERE id = :id", [":id" => $account_id]); 60 | } 61 | } 62 | 63 | } 64 | ?> -------------------------------------------------------------------------------- /public/show_services.php: -------------------------------------------------------------------------------- 1 | is_archived == 0 && ($auth->belongsTo(Authenticator::administrator) || $value->is_prod == 0)) { 12 | $accounts["a{$value->id}"] = $value->label; 13 | } 14 | }); 15 | 16 | $account_selected = key_exists("account_id_filter", $_SESSION) ? $_SESSION["account_id_filter"] : 0; 17 | $show_archived = key_exists("archived_service_filter", $_SESSION) ? $_SESSION["archived_service_filter"] : false; 18 | $checked = $show_archived ? " checked" : ""; 19 | 20 | include "header.php"; 21 | ?> 22 |
23 |

Systems

24 |
25 | Filter by account: "--All"], $accounts), "a".$account_selected) ?> 26 | | /> Show archived systems 27 | 28 |
29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | services_filtered($account_selected, $show_archived) as $row) { 45 | $is_archived = $row->is_archived == 1 ? "Yes" : "No"; 46 | print " 47 | 48 | "; 51 | } 52 | ?> 53 |
NameAccountGitHub repoSystem URLOwnerBilling codeInfrastructure TagArchived?DescriptionActions
{$row->name}{$row->label}{$row->repository}{$row->url}{$row->owner}{$row->billing_code}{$row->tag}$is_archived$row->descriptionEdit | 49 | Resources | 50 | Billing
54 |
55 |

Add system

56 |
57 | $accounts], []) ?> 58 | 59 |
60 |
61 | 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chandika 2 | 3 | Chandika provides information on the resources being used by our services, allows new resources to be created and maintained, and is intended to be used with [Raktabija](https://github.com/18F/raktabija) to destroy unused resources. 4 | 5 | For more on the motivation behind Chandika, read the blog post [Patterns for managing multi-tenant cloud environments](https://18f.gsa.gov/2016/08/10/patterns-for-managing-multi-tenant-cloud-environments/) 6 | 7 | ## Developing and testing locally 8 | 9 | Chandika uses [Scotch Box](https://box.scotch.io/) as a testing environment. Check out the code, run `vagrant up`, and hit `192.168.33.20`. 10 | 11 | ## Deploying to cloud.gov 12 | 13 | Once you've [set up your credentials on cloud.gov](https://docs.cloud.gov/getting-started/setup/) and [associated a mysql database](https://docs.cloud.gov/apps/managed-services/), simply type the following 14 | 15 | ``` 16 | cd public 17 | cf set-env [appname] CHANDIKA_OAUTH OFF # unless you want OAuth enabled, see section below 18 | cf push [appname] 19 | ``` 20 | 21 | ## OAuth integration 22 | 23 | Chandika integrates with GitHub's OAuth. If you'd like to enable OAuth integration, you need to set two environment variables: 24 | 25 | ``` 26 | CHANDIKA_OAUTH_CLIENT_ID # this is the Client ID you get when creating an OAuth application on GitHub 27 | CHANDIKA_OAUTH_CLIENT_SECRET # this is the Client Secret you get when creating an OAuth application on GitHub 28 | ``` 29 | 30 | If you're deploying to cloud.gov, you can use the following commands to set these environment variables (remember to unset `CHANDIKA_OAUTH` with `cf unset-env [appname] CHANDIKA_OAUTH`) 31 | 32 | ``` 33 | cf set-env [appname] CHANDIKA_OAUTH_CLIENT_ID 34 | cf set-env [appname] CHANDIKA_OAUTH_CLIENT_SECRET 35 | ``` 36 | 37 | If you're deploying locally with Vagrant, you can set these variables in the Vagrantfile (see the existing entry for `CHANDIKA_OAUTH` and then type `vagrant provision`. 38 | 39 | You can also limit who can log into Chandika. Chandika asks GitHub to which organization the user logging in belongs. To limit access only to GitHub users in a particular organization (or set of organizations), set the environment variable `CHANDIKA_OAUTH_ORGS` to a comma-separated list of acceptable organizations. 40 | 41 | ## The origin of the name Chandika 42 | 43 | The demon Raktabija had a superpower that meant that when a drop of his blood hit the ground, a new duplicate Raktabija would be created. Thus when the goddess Kali fought him, every time she wounded him, multiple new Raktabijas would be created. The goddess Chandika helped Kali kill all the clone Raktabijas and eventually killed Raktabija himself. The Chandika app is designed to help you kill the profusion of unused virtual resources that accumulate in a typical cloud environment. 44 | 45 | # Public domain 46 | 47 | This project is in the worldwide [public domain](LICENSE.md). As stated in [CONTRIBUTING](CONTRIBUTING.md): 48 | 49 | > This project is in the public domain within the United States, and copyright and related rights in the work worldwide are waived through the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/). 50 | 51 | > All contributions to this project will be released under the CC0 dedication. By submitting a pull request, you are agreeing to comply with this waiver of copyright interest. 52 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Open Sans'; 3 | } 4 | 5 | @media screen and (min-width: 940px) { 6 | 7 | input[type="text"] { 8 | width: 400px; 9 | margin-top: 20px; 10 | } 11 | 12 | input[type="submit"] { 13 | margin-top: 30px; 14 | } 15 | 16 | select { 17 | margin-top: 20px; 18 | } 19 | 20 | .shortinput { 21 | width: 100px !important; 22 | } 23 | 24 | label { 25 | width: 500px; 26 | float: left; 27 | text-align: right; 28 | clear: left; 29 | } 30 | } 31 | 32 | html, body { 33 | height: 100%; 34 | } 35 | 36 | body { 37 | padding-top: 50px; 38 | } 39 | 40 | header { 41 | text-align: center; 42 | width: 100%; 43 | } 44 | 45 | .header-text { 46 | text-align: right; 47 | width: 100%; 48 | } 49 | 50 | nav { 51 | font-size: 150%; 52 | } 53 | 54 | #main { 55 | padding: 5px; 56 | padding-top: 40px; 57 | width: 1150px; 58 | margin: 0 auto 0 auto; 59 | } 60 | 61 | .main { 62 | margin: 0 auto 0 auto; 63 | } 64 | 65 | .form { 66 | padding: 5px; 67 | } 68 | 69 | table, th, td { 70 | border: 1px solid #aaaaaa; 71 | } 72 | 73 | table { 74 | border-collapse: collapse; 75 | } 76 | 77 | td, th { 78 | text-align: left; 79 | padding: 5px; 80 | } 81 | 82 | th { 83 | vertical-align: bottom; 84 | } 85 | 86 | .navbuttons { 87 | text-align: center; 88 | } 89 | 90 | .navbutton { 91 | border: 1px solid black; 92 | padding: 5px; 93 | font-weight: bold; 94 | color: #ffffff; 95 | background-color: #3366ff; 96 | } 97 | 98 | ::-moz-selection { 99 | text-shadow: none; 100 | background: #fed136; 101 | } 102 | 103 | ::selection { 104 | text-shadow: none; 105 | background: #fed136; 106 | } 107 | 108 | label { 109 | display: block; 110 | padding-right: 5px; 111 | padding-top: 20px; 112 | } 113 | 114 | .btn-social { 115 | display: inline-block; 116 | font-weight: 400; 117 | text-align: center; 118 | vertical-align: middle; 119 | cursor: pointer; 120 | background-image: none; 121 | border: 1px solid transparent; 122 | white-space: nowrap; 123 | font-size: 16px; 124 | border-radius: 4px; 125 | -webkit-user-select: none; 126 | -moz-user-select: none; 127 | -ms-user-select: none; 128 | user-select: none; 129 | font-family: 'Open Sans', Helvetica, Arial, sans-serif; 130 | text-decoration: none; 131 | padding: 0 10px 0 0; 132 | margin-bottom: 15px; 133 | line-height: 2.2em; 134 | background-color: #1E6586; 135 | border-color: #00495C; 136 | color: #F9F9F9 137 | } 138 | 139 | .btn-social:active:focus, .btn-social:focus { 140 | outline: dotted thin; 141 | outline: -webkit-focus-ring-color auto 5px; 142 | outline-offset: -2px 143 | } 144 | 145 | .btn-social:active { 146 | outline: 0; 147 | background-image: none; 148 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); 149 | color: #fff 150 | } 151 | 152 | .btn-social:before { 153 | font-family: FontAwesome; 154 | content: "\f09b"; 155 | border-right: .075em solid rgba(0, 0, 0, .15); 156 | float: left; 157 | -webkit-font-smoothing: antialiased; 158 | font-smoothing: antialiased; 159 | font-size: 120%; 160 | margin: 0 .5em 0 0; 161 | padding: 0 .5em 162 | } 163 | 164 | .btn-social:focus, .btn-social:hover { 165 | background-color: #00495C; 166 | color: #fff 167 | } -------------------------------------------------------------------------------- /public/header.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Chandika 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 79 | 80 | -------------------------------------------------------------------------------- /public/classes/Authenticator.php: -------------------------------------------------------------------------------- 1 | users(), function ($value, $key) { 17 | $this->administrators[] = $value->email; 18 | }); 19 | session_start(); 20 | $oauth = getenv("CHANDIKA_OAUTH"); 21 | if ($oauth != "OFF") { 22 | if (!isset($_SESSION["user_email"])) { 23 | if (isset($_REQUEST["code"])) { 24 | $this->http_client = new Client(["base_uri" => "https://github.com"]); 25 | $oauth_request_token = $this->oauth_request_token($_REQUEST["code"]); 26 | 27 | parse_str($this->http_request("POST", "/login/oauth/access_token", ['body' => $oauth_request_token]), $token); 28 | $_SESSION["oauth_token"] = $token["access_token"]; 29 | 30 | $profile_data = json_decode($this->http_request("GET", "https://api.github.com/user?access_token=" . $token["access_token"], []), true); 31 | 32 | $org_data = json_decode($this->http_request("GET", "https://api.github.com/user/orgs?access_token=" . $token["access_token"], []), true); 33 | 34 | $my_orgs = []; 35 | foreach ($org_data as $org) { 36 | $my_orgs[] = $org["login"]; 37 | } 38 | 39 | $orgs = getenv("CHANDIKA_OAUTH_ORGS"); 40 | if ($orgs !== false && count(array_intersect($my_orgs, explode(",", $orgs))) == 0) { 41 | $error = urlencode("Must be a member of an approved organization."); 42 | header("location: /login.php?error=" . $error); 43 | die(); 44 | } 45 | if (isset($profile_data["email"])) { 46 | $_SESSION["user_email"] = $profile_data["email"]; 47 | header("location: /index.php"); 48 | } else { 49 | header("location: /login.php"); 50 | } 51 | die(); 52 | } 53 | header("location: /login.php"); 54 | die(); 55 | } 56 | $this->user_email = $_SESSION["user_email"]; 57 | } 58 | } 59 | 60 | public function user_email() 61 | { 62 | return $this->user_email; 63 | } 64 | 65 | private function oauth_request_token($code) 66 | { 67 | $token = []; 68 | $token["client_id"] = getenv("CHANDIKA_OAUTH_CLIENT_ID"); 69 | $token["client_secret"] = getenv("CHANDIKA_OAUTH_CLIENT_SECRET"); 70 | $token["code"] = $code; 71 | return http_build_query($token); 72 | } 73 | 74 | public function assertRole($role) 75 | { 76 | $oauth = getenv("CHANDIKA_OAUTH"); 77 | if ($role != Authenticator::administrator || $oauth == "OFF") { 78 | return true; 79 | } 80 | if (!in_array($_SESSION["user_email"], $this->administrators)) { 81 | header("location: /index.php"); 82 | die(); 83 | } 84 | return true; 85 | } 86 | 87 | public function belongsTo($role) 88 | { 89 | $oauth = getenv("CHANDIKA_OAUTH"); 90 | if ($oauth == "OFF") return true; 91 | return ($role == Authenticator::administrator) && in_array($_SESSION["user_email"], $this->administrators); 92 | } 93 | 94 | public function http_request($method, $uri, $data) 95 | { 96 | try { 97 | $response = $this->http_client->request($method, $uri, $data); 98 | } catch (RequestException $e) { 99 | error_log($e->getResponse()->getBody()); 100 | header("location: /login.php?error=OAuth%20Error"); 101 | die(); 102 | } 103 | return $response->getBody(); 104 | } 105 | } -------------------------------------------------------------------------------- /public/classes/ServiceAdministrator.php: -------------------------------------------------------------------------------- 1 | auth = $auth; 12 | $accounts = AccountAdministrator::accounts(); 13 | $this->is_admin = $this->auth->belongsTo(Authenticator::administrator); 14 | foreach ($accounts as $account) { 15 | if ($account->is_prod == 0 || $this->is_admin) { 16 | $this->accounts_permitted[] = $account->id; 17 | } 18 | } 19 | } 20 | 21 | public function services() 22 | { 23 | $all_services = DB::query("SELECT s.id, s.name, s.account_id, s.is_archived, s.description, a.label, s.repository, s.url, s.owner, s.billing_code, s.tag, a.is_prod 24 | FROM services s LEFT JOIN accounts a ON s.account_id = a.id ORDER BY s.name"); 25 | $services = []; 26 | foreach ($all_services as $service) { 27 | if ($service->is_prod == 0 || $this->is_admin) { 28 | $services[] = $service; 29 | } 30 | } 31 | return $services; 32 | } 33 | 34 | public function services_filtered($account_selected, $show_archived) 35 | { 36 | $filtered = []; 37 | foreach ($this->services() as $service) { 38 | $account_checks_out = $account_selected == 0 || $account_selected == $service->account_id; 39 | $archived_status_checks_out = $show_archived || $service->is_archived == 0; 40 | if ($account_checks_out && $archived_status_checks_out) { 41 | $filtered[] = $service; 42 | } 43 | } 44 | return $filtered; 45 | } 46 | 47 | public function service($id) 48 | { 49 | foreach ($this->services() as $service) { 50 | if ($service->id == $id) return $service; 51 | } 52 | return null; 53 | } 54 | 55 | public function create($properties) 56 | { 57 | if ($properties["account_id"][0] == 'a') { 58 | $properties["account_id"] = substr($properties["account_id"], 1); 59 | } 60 | if (!in_array($properties["account_id"], $this->accounts_permitted)) { 61 | return; 62 | } 63 | $insert = DB::connection()->prepare("INSERT INTO services (name, account_id, repository, owner, url, billing_code, tag, description, is_archived) 64 | VALUES (:name, :account_id, :repository, :owner, :url, :billing_code, :tag, :description, :is_archived)"); 65 | self::helper()->bind($insert, $properties); 66 | $insert->execute(); 67 | } 68 | 69 | public function update($id, $properties) 70 | { 71 | if (!in_array($properties["account_id"], $this->accounts_permitted)) { 72 | return; 73 | } 74 | $update = DB::connection()->prepare("UPDATE services SET name = :name, account_id = :account_id, repository = :repository, owner = :owner, url = :url, billing_code = :billing_code, tag = :tag, is_archived = :is_archived, description = :description WHERE id = :id"); 75 | self::helper()->bind($update, $properties); 76 | $update->bindParam(":id", $id); 77 | $update->execute(); 78 | } 79 | 80 | private static function helper() 81 | { 82 | $fields = [ 83 | [CrudHelper::name => "name", CrudHelper::desc => "System name"], 84 | [CrudHelper::name => "owner", CrudHelper::desc => "Owner's email id"], 85 | [CrudHelper::name => "account_id", CrudHelper::desc => "Account", CrudHelper::type => CrudHelper::dropdown], 86 | [CrudHelper::name => "repository", CrudHelper::desc => "GitHub repo"], 87 | [CrudHelper::name => "url", CrudHelper::desc => "Service URL"], 88 | [CrudHelper::name => "billing_code", CrudHelper::desc => "Billing code (TOCK)"], 89 | [CrudHelper::name => "tag", CrudHelper::desc => "Infrastructure Tag"], 90 | [CrudHelper::name => "description", CrudHelper::desc => "Description"], 91 | [CrudHelper::name => "is_archived", CrudHelper::desc => "Archived", CrudHelper::type => CrudHelper::checkbox] 92 | ]; 93 | return new CrudHelper($fields); 94 | } 95 | 96 | public static function form($options, $selected) 97 | { 98 | return self::helper()->form($options, $selected); 99 | } 100 | } 101 | 102 | ?> 103 | -------------------------------------------------------------------------------- /public/classes/ResourceAdministrator.php: -------------------------------------------------------------------------------- 1 | [ "prod" => 365, "non-prod" => 30 ], "IAA" => [], "HTTPS certificate" => [], "Domain name" => [], "ATO" => [] ]; 8 | 9 | public function __construct($service_id) 10 | { 11 | $this->service_id = $service_id; 12 | $services = DB::query("SELECT s.name, a.identifier, a.is_prod FROM services s LEFT JOIN accounts a ON s.account_id = a.id WHERE s.id = ?", [$service_id]); 13 | $this->service_info = $services[0]; 14 | } 15 | 16 | public function resources() 17 | { 18 | $results = []; 19 | $sql = "SELECT id, resource_type, owner, uri, created, expires FROM resources WHERE service_id = {$this->service_id} ORDER BY resource_type"; 20 | foreach (DB::connection()->query($sql, PDO::FETCH_OBJ) as $row) { 21 | $results[] = $row; 22 | } 23 | return $results; 24 | } 25 | 26 | private function get_expiry_date($resource_type, $expires) { 27 | if (!array_key_exists($resource_type, self::$types_with_expiry)) throw new Exception("Couldn't find type ".$resource_type); 28 | $expiry_data = self::$types_with_expiry[$resource_type]; 29 | if (empty($expiry_data)) return $expires; 30 | $days_to_add = $this->service_info->is_prod ? $expiry_data["prod"] : $expiry_data["non-prod"]; 31 | return time() + (24 * 60 * 60 * $days_to_add); 32 | } 33 | 34 | public function create($resource_type, $owner, $uri, $expires) 35 | { 36 | $insert = DB::connection()->prepare("INSERT INTO resources (service_id, resource_type, owner, uri, expires) VALUES (:service_id, :resource_type, :owner, :uri, :expires)"); 37 | $insert->bindParam(':service_id', $this->service_id); 38 | $insert->bindParam(':resource_type', $resource_type); 39 | $insert->bindParam(':owner', $owner); 40 | $insert->bindParam(':uri', $uri); 41 | $expiry_date = $this->get_expiry_date($resource_type, $expires); 42 | $insert->bindParam(':expires', $expiry_date); 43 | $insert->execute(); 44 | } 45 | 46 | public static function delete($id) 47 | { 48 | $delete = DB::connection()->prepare("DELETE FROM resources WHERE id = :id"); 49 | $delete->bindParam(':id', $id); 50 | $delete->execute(); 51 | } 52 | 53 | public static function types() 54 | { 55 | return array_keys(self::$types_with_expiry); 56 | } 57 | 58 | public static function all($expiry_days, $service_id, $resource_type) { 59 | $results = []; 60 | $sql = "SELECT s.id AS service_id, s.name AS service_name, a.label AS account_label, r.resource_type, r.uri AS resource_uri, r.expires 61 | FROM resources r LEFT JOIN services s ON r.service_id = s.id LEFT JOIN accounts a ON s.account_id = a.id WHERE (r.expires > UNIX_TIMESTAMP() OR r.expires IS NULL)"; 62 | $where = new WhereConstructor(false); 63 | $where->addParam("(r.expires IS NULL OR r.expires < (UNIX_TIMESTAMP() + :days))", ":days", $expiry_days * 3600 * 24); 64 | $where->addParam("s.id = :service_id", ":service_id", $service_id); 65 | $where->addParam("r.resource_type = :resource_type", ":resource_type", $resource_type); 66 | $statement = DB::connection()->prepare($sql.$where->where()); 67 | $statement->execute($where->params()); 68 | while ($row = $statement->fetch(PDO::FETCH_OBJ)) { 69 | $results[] = $row; 70 | } 71 | return $results; 72 | } 73 | 74 | public function name() { 75 | return $this->service_info->name; 76 | } 77 | 78 | public function account_identifier() 79 | { 80 | return $this->service_info->identifier; 81 | } 82 | 83 | } 84 | 85 | class WhereConstructor { 86 | 87 | private $params = []; 88 | private $where_clause; 89 | 90 | public function __construct($include_where) 91 | { 92 | $this->where_clause = $include_where ? " WHERE " : " AND "; 93 | } 94 | 95 | 96 | public function addParam($where, $param_name, $param_value) { 97 | if (!empty($param_value)) { 98 | if (!empty($this->params)) $this->where_clause .= " AND "; 99 | $this->where_clause .= $where; 100 | $this->params[$param_name] = $param_value; 101 | } 102 | } 103 | 104 | public function where() { 105 | if (empty($this->params)) return ""; 106 | return $this->where_clause; 107 | } 108 | 109 | public function params() { 110 | return $this->params; 111 | } 112 | } 113 | ?> -------------------------------------------------------------------------------- /public/composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", 5 | "This file is @generated automatically" 6 | ], 7 | "hash": "253eab539913026dd932b62d5dd19fb8", 8 | "content-hash": "83a261e7a662e275d2200290616b109d", 9 | "packages": [ 10 | { 11 | "name": "guzzlehttp/guzzle", 12 | "version": "6.2.0", 13 | "source": { 14 | "type": "git", 15 | "url": "https://github.com/guzzle/guzzle.git", 16 | "reference": "d094e337976dff9d8e2424e8485872194e768662" 17 | }, 18 | "dist": { 19 | "type": "zip", 20 | "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d094e337976dff9d8e2424e8485872194e768662", 21 | "reference": "d094e337976dff9d8e2424e8485872194e768662", 22 | "shasum": "" 23 | }, 24 | "require": { 25 | "guzzlehttp/promises": "~1.0", 26 | "guzzlehttp/psr7": "~1.1", 27 | "php": ">=5.5.0" 28 | }, 29 | "require-dev": { 30 | "ext-curl": "*", 31 | "phpunit/phpunit": "~4.0", 32 | "psr/log": "~1.0" 33 | }, 34 | "type": "library", 35 | "extra": { 36 | "branch-alias": { 37 | "dev-master": "6.2-dev" 38 | } 39 | }, 40 | "autoload": { 41 | "files": [ 42 | "src/functions_include.php" 43 | ], 44 | "psr-4": { 45 | "GuzzleHttp\\": "src/" 46 | } 47 | }, 48 | "notification-url": "https://packagist.org/downloads/", 49 | "license": [ 50 | "MIT" 51 | ], 52 | "authors": [ 53 | { 54 | "name": "Michael Dowling", 55 | "email": "mtdowling@gmail.com", 56 | "homepage": "https://github.com/mtdowling" 57 | } 58 | ], 59 | "description": "Guzzle is a PHP HTTP client library", 60 | "homepage": "http://guzzlephp.org/", 61 | "keywords": [ 62 | "client", 63 | "curl", 64 | "framework", 65 | "http", 66 | "http client", 67 | "rest", 68 | "web service" 69 | ], 70 | "time": "2016-03-21 20:02:09" 71 | }, 72 | { 73 | "name": "guzzlehttp/promises", 74 | "version": "1.1.0", 75 | "source": { 76 | "type": "git", 77 | "url": "https://github.com/guzzle/promises.git", 78 | "reference": "bb9024c526b22f3fe6ae55a561fd70653d470aa8" 79 | }, 80 | "dist": { 81 | "type": "zip", 82 | "url": "https://api.github.com/repos/guzzle/promises/zipball/bb9024c526b22f3fe6ae55a561fd70653d470aa8", 83 | "reference": "bb9024c526b22f3fe6ae55a561fd70653d470aa8", 84 | "shasum": "" 85 | }, 86 | "require": { 87 | "php": ">=5.5.0" 88 | }, 89 | "require-dev": { 90 | "phpunit/phpunit": "~4.0" 91 | }, 92 | "type": "library", 93 | "extra": { 94 | "branch-alias": { 95 | "dev-master": "1.0-dev" 96 | } 97 | }, 98 | "autoload": { 99 | "psr-4": { 100 | "GuzzleHttp\\Promise\\": "src/" 101 | }, 102 | "files": [ 103 | "src/functions_include.php" 104 | ] 105 | }, 106 | "notification-url": "https://packagist.org/downloads/", 107 | "license": [ 108 | "MIT" 109 | ], 110 | "authors": [ 111 | { 112 | "name": "Michael Dowling", 113 | "email": "mtdowling@gmail.com", 114 | "homepage": "https://github.com/mtdowling" 115 | } 116 | ], 117 | "description": "Guzzle promises library", 118 | "keywords": [ 119 | "promise" 120 | ], 121 | "time": "2016-03-08 01:15:46" 122 | }, 123 | { 124 | "name": "guzzlehttp/psr7", 125 | "version": "1.3.0", 126 | "source": { 127 | "type": "git", 128 | "url": "https://github.com/guzzle/psr7.git", 129 | "reference": "31382fef2889136415751badebbd1cb022a4ed72" 130 | }, 131 | "dist": { 132 | "type": "zip", 133 | "url": "https://api.github.com/repos/guzzle/psr7/zipball/31382fef2889136415751badebbd1cb022a4ed72", 134 | "reference": "31382fef2889136415751badebbd1cb022a4ed72", 135 | "shasum": "" 136 | }, 137 | "require": { 138 | "php": ">=5.4.0", 139 | "psr/http-message": "~1.0" 140 | }, 141 | "provide": { 142 | "psr/http-message-implementation": "1.0" 143 | }, 144 | "require-dev": { 145 | "phpunit/phpunit": "~4.0" 146 | }, 147 | "type": "library", 148 | "extra": { 149 | "branch-alias": { 150 | "dev-master": "1.0-dev" 151 | } 152 | }, 153 | "autoload": { 154 | "psr-4": { 155 | "GuzzleHttp\\Psr7\\": "src/" 156 | }, 157 | "files": [ 158 | "src/functions_include.php" 159 | ] 160 | }, 161 | "notification-url": "https://packagist.org/downloads/", 162 | "license": [ 163 | "MIT" 164 | ], 165 | "authors": [ 166 | { 167 | "name": "Michael Dowling", 168 | "email": "mtdowling@gmail.com", 169 | "homepage": "https://github.com/mtdowling" 170 | } 171 | ], 172 | "description": "PSR-7 message implementation", 173 | "keywords": [ 174 | "http", 175 | "message", 176 | "stream", 177 | "uri" 178 | ], 179 | "time": "2016-04-13 19:56:01" 180 | }, 181 | { 182 | "name": "psr/http-message", 183 | "version": "1.0", 184 | "source": { 185 | "type": "git", 186 | "url": "https://github.com/php-fig/http-message.git", 187 | "reference": "85d63699f0dbedb190bbd4b0d2b9dc707ea4c298" 188 | }, 189 | "dist": { 190 | "type": "zip", 191 | "url": "https://api.github.com/repos/php-fig/http-message/zipball/85d63699f0dbedb190bbd4b0d2b9dc707ea4c298", 192 | "reference": "85d63699f0dbedb190bbd4b0d2b9dc707ea4c298", 193 | "shasum": "" 194 | }, 195 | "require": { 196 | "php": ">=5.3.0" 197 | }, 198 | "type": "library", 199 | "extra": { 200 | "branch-alias": { 201 | "dev-master": "1.0.x-dev" 202 | } 203 | }, 204 | "autoload": { 205 | "psr-4": { 206 | "Psr\\Http\\Message\\": "src/" 207 | } 208 | }, 209 | "notification-url": "https://packagist.org/downloads/", 210 | "license": [ 211 | "MIT" 212 | ], 213 | "authors": [ 214 | { 215 | "name": "PHP-FIG", 216 | "homepage": "http://www.php-fig.org/" 217 | } 218 | ], 219 | "description": "Common interface for HTTP messages", 220 | "keywords": [ 221 | "http", 222 | "http-message", 223 | "psr", 224 | "psr-7", 225 | "request", 226 | "response" 227 | ], 228 | "time": "2015-05-04 20:22:00" 229 | } 230 | ], 231 | "packages-dev": [], 232 | "aliases": [], 233 | "minimum-stability": "stable", 234 | "stability-flags": [], 235 | "prefer-stable": false, 236 | "prefer-lowest": false, 237 | "platform": [], 238 | "platform-dev": [] 239 | } 240 | -------------------------------------------------------------------------------- /public/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.2.0 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){return a(b.target).is(this)?b.handleObj.handler.apply(this,arguments):void 0}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.2.0",d.prototype.close=function(b){function c(){f.detach().trigger("closed.bs.alert").remove()}var d=a(this),e=d.attr("data-target");e||(e=d.attr("href"),e=e&&e.replace(/.*(?=#[^\s]*$)/,""));var f=a(e);b&&b.preventDefault(),f.length||(f=d.hasClass("alert")?d:d.parent()),f.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(f.removeClass("in"),a.support.transition&&f.hasClass("fade")?f.one("bsTransitionEnd",c).emulateTransitionEnd(150):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.2.0",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),d[e](null==f[b]?this.options[b]:f[b]),setTimeout(a.proxy(function(){"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")&&(c.prop("checked")&&this.$element.hasClass("active")?a=!1:b.find(".active").removeClass("active")),a&&c.prop("checked",!this.$element.hasClass("active")).trigger("change")}a&&this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target);d.hasClass("btn")||(d=d.closest(".btn")),b.call(d,"toggle"),c.preventDefault()})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b).on("keydown.bs.carousel",a.proxy(this.keydown,this)),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=this.sliding=this.interval=this.$active=this.$items=null,"hover"==this.options.pause&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.2.0",c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0},c.prototype.keydown=function(a){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.to=function(b){var c=this,d=this.getItemIndex(this.$active=this.$element.find(".item.active"));return b>this.$items.length-1||0>b?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){c.to(b)}):d==b?this.pause().cycle():this.slide(b>d?"next":"prev",a(this.$items[b]))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){return this.sliding?void 0:this.slide("next")},c.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},c.prototype.slide=function(b,c){var d=this.$element.find(".item.active"),e=c||d[b](),f=this.interval,g="next"==b?"left":"right",h="next"==b?"first":"last",i=this;if(!e.length){if(!this.options.wrap)return;e=this.$element.find(".item")[h]()}if(e.hasClass("active"))return this.sliding=!1;var j=e[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:g});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,f&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(e)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:g});return a.support.transition&&this.$element.hasClass("slide")?(e.addClass(b),e[0].offsetWidth,d.addClass(g),e.addClass(g),d.one("bsTransitionEnd",function(){e.removeClass([b,g].join(" ")).addClass("active"),d.removeClass(["active",g].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(1e3*d.css("transition-duration").slice(0,-1))):(d.removeClass("active"),e.addClass("active"),this.sliding=!1,this.$element.trigger(m)),f&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this},a(document).on("click.bs.carousel.data-api","[data-slide], [data-slide-to]",function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}}),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.collapse"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b);!e&&f.toggle&&"show"==b&&(b=!b),e||d.data("bs.collapse",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.transitioning=null,this.options.parent&&(this.$parent=a(this.options.parent)),this.options.toggle&&this.toggle()};c.VERSION="3.2.0",c.DEFAULTS={toggle:!0},c.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},c.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var c=a.Event("show.bs.collapse");if(this.$element.trigger(c),!c.isDefaultPrevented()){var d=this.$parent&&this.$parent.find("> .panel > .in");if(d&&d.length){var e=d.data("bs.collapse");if(e&&e.transitioning)return;b.call(d,"hide"),e||d.data("bs.collapse",null)}var f=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[f](0),this.transitioning=1;var g=function(){this.$element.removeClass("collapsing").addClass("collapse in")[f](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return g.call(this);var h=a.camelCase(["scroll",f].join("-"));this.$element.one("bsTransitionEnd",a.proxy(g,this)).emulateTransitionEnd(350)[f](this.$element[0][h])}}},c.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse").removeClass("in"),this.transitioning=1;var d=function(){this.transitioning=0,this.$element.trigger("hidden.bs.collapse").removeClass("collapsing").addClass("collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(d,this)).emulateTransitionEnd(350):d.call(this)}}},c.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()};var d=a.fn.collapse;a.fn.collapse=b,a.fn.collapse.Constructor=c,a.fn.collapse.noConflict=function(){return a.fn.collapse=d,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(c){var d,e=a(this),f=e.attr("data-target")||c.preventDefault()||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""),g=a(f),h=g.data("bs.collapse"),i=h?"toggle":e.data(),j=e.attr("data-parent"),k=j&&a(j);h&&h.transitioning||(k&&k.find('[data-toggle="collapse"][data-parent="'+j+'"]').not(e).addClass("collapsed"),e[g.hasClass("in")?"addClass":"removeClass"]("collapsed")),b.call(g,i)})}(jQuery),+function(a){"use strict";function b(b){b&&3===b.which||(a(e).remove(),a(f).each(function(){var d=c(a(this)),e={relatedTarget:this};d.hasClass("open")&&(d.trigger(b=a.Event("hide.bs.dropdown",e)),b.isDefaultPrevented()||d.removeClass("open").trigger("hidden.bs.dropdown",e))}))}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.2.0",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a('