diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..945071b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +development.pem diff --git a/README.md b/README.md index 6ccd2d9..b6054a4 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,32 @@ or ``` Then navigate to `localhost:8181` in a browser. + +### Oppia Android Project Dashboard + +The Oppia Project Dashboard uses the web crypto API, which requires a TLS connection. +To start an HTTPS simple server with a generated self-signed certificate, execute: + +```shell +$ python3 start.py +``` + +Then navigate to `https://localhost:8181/project-dashboard` in a browser. Note that +you will need to explicitly permit the browser to accept this self-signed certificate. +Chrome won't let you do that, but there's a workaround. Just click anywhere on the +error page and type `thisisunsafe` and the page will load. + +#### Windows + +If you are using Windows, you have a few options: + +1. You can install a VM or spin up a Docker container with Linux, and run the script + from there, using a shared volume and forwarding tcp/8181 to the windows host. +2. You can install the Linux subsystem for Windows under Add or remove programs > + Windows Features. Then, in the Microsoft Store you can download a linux distribution + such as Ubuntu. +3. You can install OpenSSL for Windows and manually run this command to generate the + `development.pem` file: + ```shell + $ /bin/bash -c "openssl req -new -x509 -keyout development.pem -out development.pem -days 365 -nodes -subj /CN=localhost/ -reqexts SAN -extensions SAN -config <(cat /etc/ssl/openssl.cnf <(printf '[SAN]\nsubjectAltName=DNS:localhost'))" + ``` diff --git a/project-dashboard/index.html b/project-dashboard/index.html index ec0476a..a9ddf67 100644 --- a/project-dashboard/index.html +++ b/project-dashboard/index.html @@ -4,9 +4,28 @@ Project Dashboard + + -

Project Dashboard

- +
+

Authenticate with Github

+

In order to use the Oppia Project Dashboard, you need to provide a personal access token.

+
+ + +
+
    +
  1. Navigate to https://github.com/settings/tokens and login if necessary.
  2. +
  3. Click "Generate new token"
  4. +
  5. You can use "Oppia Project Dashboard" as the note.
  6. +
  7. Select only the "public_repo" checkbox. Then click Generate Token.
  8. +
  9. Paste the token in the field above and click Login.
  10. +
+
+
+

Project Dashboard

+
+ diff --git a/project-dashboard/scripts/db.js b/project-dashboard/scripts/db.js new file mode 100644 index 0000000..9f69b85 --- /dev/null +++ b/project-dashboard/scripts/db.js @@ -0,0 +1,97 @@ +/** + * An abstraction layer for IndexedDB. + * + * @module + */ + +const name = 'OppiaProjectDashboard'; +const version = 1; // long long; incrementing fires onupgradeneeded event +let db = null; + +/** + * Open IndexedDB. + * + * @returns {boolean} + */ +const open = async () => { + let handle = new Promise((resolve, reject) => { + let req = indexedDB.open(name, version); + req.onsuccess = event => resolve(event.target.result); + req.onupgradeneeded = (event) => { + let db = event.target.result; + + // Create keystore + if (!db.objectStoreNames.contains('keystore')) { + let keystore = db.createObjectStore('keystore', { autoIncrement: true, keyPath: 'name' }); + keystore.createIndex('name', 'name', { unique: true }); + } + + // Create datastore + if (!db.objectStoreNames.contains('datastore')) { + let datastore = db.createObjectStore('datastore', { autoIncrement: true }); + // TODO: Create indexes + } + + resolve(db); + }; + req.onerror = reject; + req.onblocked = reject; + }); + + db = await handle; + return true; +}; + +/** + * Close IndexedDB. + * + * @returns {boolean} + */ +const close = async () => { + if (db) { + await db.close(); + db = null; + } + return true +}; + +/** + * Write a key into the keystore. + * + * @param {string} name - Name of the key to store (must be unique). + * @param {CryptoKey} key + * @returns {boolean} + */ +const setKey = async (name, key) => { + if (!db) await open(); + + let transaction = db.transaction(['keystore'], 'readwrite'); + let objectStore = transaction.objectStore('keystore'); + await objectStore.add({ name, key }); + return true; +}; + +/** + * Get a key from the keystore. + * + * @param {string} name + * @returns {CryptoKey} + */ +const getKey = async (name) => { + if (!db) await open(); + + let key = new Promise((resolve, reject) => { + let transaction = db.transaction(['keystore'], 'readonly'); + let objectStore = transaction.objectStore('keystore'); + let op = objectStore.get(name); + op.onsuccess = (event) => resolve(event.target.result); + op.onerror = reject; + }); + + await key; + return key; +}; + +// TODO(55): Write getters and setters for datastore + +export { open, close, setKey, getKey }; diff --git a/project-dashboard/scripts.js b/project-dashboard/scripts/graphql.js similarity index 52% rename from project-dashboard/scripts.js rename to project-dashboard/scripts/graphql.js index 38e3b28..d06f36d 100644 --- a/project-dashboard/scripts.js +++ b/project-dashboard/scripts/graphql.js @@ -1,18 +1,30 @@ -(async function () { - const PAT = ''; +/** + * Methods for Querying GitHub's GraphQL API. + * + * @module + */ + +/** + * Query GitHub API via GraphQL. + * + * @param {Promise} patPromise - GitHub Personal Access Token. + */ +const queryData = async (patPromise) => { + const pat = await patPromise; + const graph = graphql('https://api.github.com/graphql', { method: 'POST', asJSON: true, headers: { - 'Authorization': `bearer ${PAT}` + 'Authorization': `bearer ${pat}` }, }); - const repo_name = 'test-project-management-data'; - const repo_owner = 'BenHenning'; + const repoName = 'test-project-management-data'; + const repoOwner = 'BenHenning'; - let repository_query = graph(`query($repo_name: String!, $repo_owner: String!, $labels: [String!], $first: Int, $after: String) { - repository(name: $repo_name, owner: $repo_owner) { + let repositoryQuery = graph(`query($repoName: String!, $repoOwner: String!, $labels: [String!], $first: Int, $after: String) { + repository(name: $repoName, owner: $repoOwner) { ptis: issues(labels: $labels, first: $first, after: $after) { totalCount nodes { @@ -44,8 +56,8 @@ } }`); - let all_issues_query = graph(`query($repo_name: String!, $repo_owner: String!, $first: Int, $after: String) { - repository(name: $repo_name, owner: $repo_owner) { + let allIssuesQuery = graph(`query($repoName: String!, $repoOwner: String!, $first: Int, $after: String) { + repository(name: $repoName, owner: $repoOwner) { all_issues: issues(first: $first, after: $after) { totalCount nodes { @@ -62,8 +74,8 @@ } }`); - let milestones_query = graph(`query($repo_name: String!, $repo_owner: String!, $first: Int, $after: String) { - repository(name: $repo_name, owner: $repo_owner) { + let milestonesQuery = graph(`query($repoName: String!, $repoOwner: String!, $first: Int, $after: String) { + repository(name: $repoName, owner: $repoOwner) { milestones(first: $first, after: $after) { totalCount pageInfo { @@ -80,24 +92,26 @@ } }`); - let repositories = await repository_query({ - repo_name, - repo_owner, + let repositories = await repositoryQuery({ + repoName, + repoOwner, labels: 'Type: PTI', first: 100, }); - let all_issues = await all_issues_query({ - repo_name, - repo_owner, + let allIssues = await allIssuesQuery({ + repoName, + repoOwner, first: 100, }); - let milestones = await milestones_query({ - repo_name, - repo_owner, + let milestones = await milestonesQuery({ + repoName, + repoOwner, first: 100, }); - console.log(repositories, all_issues, milestones); -})(); + console.log(repositories, allIssues, milestones); +}; + +export default queryData; diff --git a/project-dashboard/scripts/pat.js b/project-dashboard/scripts/pat.js new file mode 100644 index 0000000..c7a92bc --- /dev/null +++ b/project-dashboard/scripts/pat.js @@ -0,0 +1,71 @@ +/** + * Methods for securely processing and storing GitHub + * Personal Access Tokens (PATs). + * + * @method + */ +import { getKey, setKey } from './db.js'; + +/** + * Get Personal Access Token from Local Storage. + * + * @returns {string} + */ +const getPat = async () => { + let ciphertext = localStorage.getItem('PAT'); + if (!ciphertext) throw new Error('No PAT currently stored in local storage'); + + let initVector = localStorage.getItem('initVector'); + if (!initVector) throw new Error('No IV currently stored in local storage'); + + // Ciphertext and IV were stored as strings in local storage + // Split by comma to convert them back into Uint8Arrays + ciphertext = Uint8Array.from(ciphertext.split(',')); + initVector = Uint8Array.from(initVector.split(',')); + + // Get the symmetric key and decrypt the ciphertext + let { key } = await getKey('pat_key'); + let pat = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: initVector }, key, ciphertext); + + // Convert plaintext data into a string + return (new TextDecoder()).decode(pat); +}; + +/** + * Set Personal Access Token to Local Storage. + * + * @param {string} pat - The Personal Access Token entered by user. + */ +const setPat = async (pat) => { + // Get encryption key + let key = await getKey('pat_key'); + + if (!key) { + // Create symmetric key for encrypting PAT for local storage + key = await crypto.subtle.generateKey({ + name: 'AES-GCM', + length: 256, + }, + false, // do not allow export + ['encrypt', 'decrypt']); + + // Save key + await setKey('pat_key', key); + } else ({ key } = key); + + // Encode PAT into Uint8Array + const encoder = new TextEncoder(); + let plaintext = encoder.encode(pat); + + // Generate initialization vector + let initVector = crypto.getRandomValues(new Uint8Array(12)); + + // Encrypt plaintext + let ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: initVector }, key, plaintext); + + // Save ciphertext and IV to local storage as strings + localStorage.setItem('PAT', new Uint8Array(ciphertext).toString()); // ciphertext is of type buffer + localStorage.setItem('initVector', initVector.toString()); +}; + +export { getPat, setPat }; diff --git a/project-dashboard/scripts/startup.js b/project-dashboard/scripts/startup.js new file mode 100644 index 0000000..a158efd --- /dev/null +++ b/project-dashboard/scripts/startup.js @@ -0,0 +1,44 @@ +import { open } from './db.js'; +import { getPat, setPat } from './pat.js'; +import queryData from './graphql.js'; + +// Open Database +open(); + +// If the Personal Access Token is not in local storage, prompt user for one +if (!localStorage.getItem('PAT')) { + // Show the authenticate form and bind validation functions + const prompt = document.querySelector('#authenticate'); + const main = document.querySelector('main'); + prompt.classList.add('show'); + main.classList.add('blur'); + + const patInput = document.querySelector('#authenticate input'); + + patInput.addEventListener('input', () => { + patInput.setCustomValidity(''); // reset message + patInput.checkValidity(); + }); + + patInput.addEventListener('invalid', () => { + patInput.setCustomValidity('A GitHub Personal Access token is a 40 character hexadecimal string'); + }); + + // Handle form submission + document.querySelector('#authenticate form').addEventListener('submit', async (event) => { + event.preventDefault(); + event.stopPropagation(); + + await setPat(patInput.value); + + // Send graphql query + queryData(getPat()); + + // Hide prompt + prompt.classList.remove('show'); + main.classList.remove('blur'); + }); +} else { + // Otherwise send graphql query + queryData(getPat()); +} diff --git a/project-dashboard/styles.css b/project-dashboard/styles.css new file mode 100644 index 0000000..8b9b973 --- /dev/null +++ b/project-dashboard/styles.css @@ -0,0 +1,36 @@ +/* Authentication Prompt */ +#authenticate { + display: flex; + position: absolute; + width: 100%; + height: 100%; + z-index: -1; + flex-direction: column; + align-items: center; + justify-content: center; + transition: opacity 1s ease-in-out; + opacity: 0; + top: 0; + pointer-events: none; +} +#authenticate.show { + opacity: 1; + pointer-events: all; + z-index: 100; +} +#authenticate form { + width: 90%; + display: flex; + justify-content: center; + margin: 20px; +} +#authenticate form input { + padding: 10px; + width: 50%; + margin-right: 10px; +} +#authenticate form ol { margin-top: 30px; } + +/* Main element */ +main { transition: filter 1s ease-in-out; } +main.blur { filter: blur(5px); } diff --git a/start.py b/start.py new file mode 100755 index 0000000..be5f3f5 --- /dev/null +++ b/start.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 + +from http.server import HTTPServer, SimpleHTTPRequestHandler, HTTPStatus +import ssl +from os import path, system + +# Generate private key and self-signed cert if it doesn't exist +if not path.isfile('development.pem'): + system('/bin/bash -c "openssl req -new -x509 -keyout development.pem -out development.pem -days 365 -nodes -subj /CN=localhost/ -reqexts SAN -extensions SAN -config <(cat /etc/ssl/openssl.cnf <(printf \'[SAN]\nsubjectAltName=DNS:localhost\'))"') + +# Start server +httpd = HTTPServer(('0.0.0.0', 8181), SimpleHTTPRequestHandler) +httpd.socket = ssl.wrap_socket(httpd.socket, server_side=True, certfile='development.pem') +httpd.serve_forever()