Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
development.pem
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest mentioning somewhere in this section that only Python 3 is supported when accessing the project dashboard (just to be explicit since above we have both Python 2 & 3 instructions).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think since Python 2 has been deprecated for over a year it might be okay to remove python2 instructions. Thoughts?

Copy link
Member

@BenHenning BenHenning Jan 27, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're planning to keep the Python 2 instructions since the Oppia web backend still uses it.

```

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'))"
```
23 changes: 21 additions & 2 deletions project-dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,28 @@
<meta charset="utf-8" />
<title>Project Dashboard</title>
<script src="https://cdn.jsdelivr.net/gh/f/[email protected]/graphql.min.js" integrity="sha256-Zo7tOMz6K/oPU5ch0Mtpp0LRQCUVX0HvyBd3gj3rc7g=" crossorigin="anonymous"></script>
<link rel="stylesheet" href="../static/css/stylesheet.css" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<h1>Project Dashboard</h1>
<script src="scripts.js"></script>
<section id="authenticate">
<h1>Authenticate with Github</h1>
<p>In order to use the Oppia Project Dashboard, you need to provide a personal access token.</p>
<form autocomplete="off">
<input type="password" placeholder="Personal Access Token" pattern="[a-f0-9]{40}" minlength="40" maxlength="40" autofocus required autocomplete="off" />
<button type="submit">Login</button>
</form>
<ol>
<li>Navigate to <a href="https://github.com/settings/tokens" target="_blank">https://github.com/settings/tokens</a> and login if necessary.</li>
<li>Click "Generate new token"</li>
<li>You can use "Oppia Project Dashboard" as the note.</li>
<li>Select only the "public_repo" checkbox. Then click Generate Token.</li>
<li>Paste the token in the field above and click Login.</li>
</ol>
</section>
<main>
<h1>Project Dashboard</h1>
</main>
<script src="scripts/startup.js" type="module"></script>
</body>
</html>
97 changes: 97 additions & 0 deletions project-dashboard/scripts/db.js
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// TODO(55): Write getters and setters for datastore
// TODO(#55): Write getters and setters for datastore


export { open, close, setKey, getKey };
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
(async function () {
const PAT = '';
/**
* Methods for Querying GitHub's GraphQL API.
*
* @module
*/

/**
* Query GitHub API via GraphQL.
*
* @param {Promise<string>} patPromise - GitHub Personal Access Token.
*/
const queryData = async (patPromise) => {
const pat = await patPromise;

const graph = graphql('https://github.com/api/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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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;
71 changes: 71 additions & 0 deletions project-dashboard/scripts/pat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Methods for securely processing and storing GitHub
* Personal Access Tokens (PATs).
*
* @method
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be @module?

*/
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 };
44 changes: 44 additions & 0 deletions project-dashboard/scripts/startup.js
Original file line number Diff line number Diff line change
@@ -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());
}
Loading