From 33a84bb2acf9626fa86c2c165b91b66a718e1526 Mon Sep 17 00:00:00 2001 From: anonymous Date: Wed, 21 Sep 2022 12:46:08 -0400 Subject: log in is now just a toggle function, no loggedIn bool --- auth.js | 119 +++++++++++++++++++++++++++++++ graffiti.js | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/auth.js | 118 ------------------------------- src/socket.js | 222 ---------------------------------------------------------- src/utils.js | 11 --- utils.js | 11 +++ 6 files changed, 352 insertions(+), 351 deletions(-) create mode 100644 auth.js create mode 100644 graffiti.js delete mode 100644 src/auth.js delete mode 100644 src/socket.js delete mode 100644 src/utils.js create mode 100644 utils.js diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..108b42a --- /dev/null +++ b/auth.js @@ -0,0 +1,119 @@ +import { randomString, sha256 } from './utils.js' + +export default { + + async logIn(graffitiURL) { + // Generate a random client secret and state + const clientSecret = randomString() + const state = randomString() + + // The client ID is the secret's hex hash + const clientID = await sha256(clientSecret) + + // Store the client secret as a local variable + window.localStorage.setItem('graffitiClientSecret', clientSecret) + window.localStorage.setItem('graffitiClientID', clientID) + window.localStorage.setItem('graffitiAuthState', state) + + // Redirect to the login window + const loginURL = this.authURL(graffitiURL) + loginURL.searchParams.set('client_id', clientID) + loginURL.searchParams.set('redirect_uri', window.location.href) + loginURL.searchParams.set('state', state) + window.location.href = loginURL + }, + + async connect(graffitiURL) { + + // Check to see if we are already logged in + let token = window.localStorage.getItem('graffitiToken') + let myID = window.localStorage.getItem('graffitiID') + + if (!token || !myID) { + // Remove them both in case one exists + // and the other does not + token = myID = null + + // Check to see if we are redirecting back + const url = new URL(window.location) + if (url.searchParams.has('code')) { + + // Extract the code and state from the URL and strip it from the history + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + url.searchParams.delete('code') + url.searchParams.delete('state') + window.history.replaceState({}, '', url) + + // Get stored variables and remove them + const clientSecret = window.localStorage.getItem('graffitiClientSecret') + const clientID = window.localStorage.getItem('graffitiClientID') + const storedState = window.localStorage.getItem('graffitiAuthState') + window.localStorage.removeItem('graffitiClientSecret') + window.localStorage.removeItem('graffitiClientID') + window.localStorage.removeItem('graffitiAuthState') + + // Make sure state has been preserved + if (state != storedState) { + throw new Error("The state in local storage does not match the state sent by the server") + } + + // Construct the body of the POST + let form = new FormData() + form.append('client_id', clientID) + form.append('client_secret', clientSecret) + form.append('code', code) + + // Ask to exchange the code for a token + const tokenURL = this.authURL(graffitiURL) + tokenURL.pathname = '/token' + const response = await fetch(tokenURL, { + method: 'post', + body: form + }) + + // Make sure the response is OK + if (!response.ok) { + let reason = response.status + ": " + try { + reason += (await response.json()).detail + } catch (e) { + reason += response.statusText + } + + throw new Error(`The authorization code could not be exchanged for a token.\n\n${reason}`) + } + + // Parse out the token + const data = await response.json() + token = data.access_token + myID = data.owner_id + + // And make sure that the token is valid + if (!token || !myID) { + throw new Error(`The authorization token could not be parsed from the response.\n\n${data}`) + } + + // Store the token and ID + window.localStorage.setItem('graffitiToken', token) + window.localStorage.setItem('graffitiID', myID) + } + } + + return { myID, token } + + }, + + logOut() { + window.localStorage.removeItem('graffitiToken') + window.localStorage.removeItem('graffitiID') + window.location.reload() + }, + + authURL(graffitiURL) { + const url = new URL(graffitiURL) + url.host = "auth." + url.host + return url + }, + +} diff --git a/graffiti.js b/graffiti.js new file mode 100644 index 0000000..7a9c168 --- /dev/null +++ b/graffiti.js @@ -0,0 +1,222 @@ +import Auth from './auth.js' +import { randomString } from './utils.js' + +export default class { + + constructor(graffitiURL="https://graffiti.csail.mit.edu") { + this.graffitiURL = graffitiURL + this.open = false + this.subscriptionData = {} + this.eventTarget = new EventTarget() + } + + // CALL THIS BEFORE DOING ANYTHING ELSE + async initialize() { + // Perform authorization + this.authParams = await Auth.connect(this.graffitiURL) + + // Rewrite the URL + this.wsURL = new URL(this.graffitiURL) + this.wsURL.host = "app." + this.wsURL.host + if (this.wsURL.protocol == 'https:') { + this.wsURL.protocol = 'wss:' + } else { + this.wsURL.protocol = 'ws:' + } + if (this.authParams.token) { + this.wsURL.searchParams.set("token", this.authParams.token) + } + + // And commence connection + this.connect() + } + + connect() { + this.ws = new WebSocket(this.wsURL) + this.ws.onmessage = this.onMessage.bind(this) + this.ws.onclose = this.onClose.bind(this) + this.ws.onopen = this.onOpen.bind(this) + } + + // authorization functions + get myID() { return this.authParams.myID } + toggleLogIn() { + this.myID? Auth.logOut() : Auth.logIn(this.graffitiURL) + } + + async onClose() { + this.open = false + console.error("lost connection to graffiti server, attemping reconnect soon...") + await new Promise(resolve => setTimeout(resolve, 2000)) + this.connect() + } + + async request(msg) { + // Create a random message ID + const messageID = randomString() + + // Create a listener for the reply + const dataPromise = new Promise(resolve => { + this.eventTarget.addEventListener(messageID, (e) => { + resolve(e.data) + }) + }) + + // Wait for the socket to open + if (!this.open) { + await new Promise(resolve => { + this.eventTarget.addEventListener("graffitiOpen", () => resolve() ) + }) + } + + // Send the request + msg.messageID = messageID + this.ws.send(JSON.stringify(msg)) + + // Await the reply + const data = await dataPromise + delete data.messageID + + if (data.type == 'error') { + throw data + } else { + return data + } + } + + onMessage(event) { + const data = JSON.parse(event.data) + + if ('messageID' in data) { + // It's a reply + // Forward it back to the sender + const messageEvent = new Event(data.messageID) + messageEvent.data = data + this.eventTarget.dispatchEvent(messageEvent) + + } else if (['updates', 'removes'].includes(data.type)) { + // Subscription data + if (data.queryID in this.subscriptionData) { + const sd = this.subscriptionData[data.queryID] + + // For each data point, either add or remove it + for (const r of data.results) { + if (data.type == 'updates') { + sd.updateCallback(r) + } else { + sd.removeCallback(r) + } + } + + // And update this query's notion of "now" + if (data.complete) { + if (data.historical) { + sd.historyComplete = true + } + if (sd.historyComplete) { + sd.since = data.now + } + } + } + } else if (data.type == 'error') { + if (data.reason == 'authorization') { + this.logOut() + } + throw data + } + } + + async update(object, query) { + const data = await this.request({ + type: "update", + object, query + }) + return data.objectID + } + + async remove(objectID) { + await this.request({ + type: "remove", + objectID + }) + } + + async subscribe( + query, + updateCallback, + removeCallback, + since=null, + queryID=null) { + + // Create a random query ID + if (!queryID) queryID = randomString() + + // Send the request + await this.request({ + type: "subscribe", + queryID, query, since + }) + + // Store the subscription in case of disconnections + this.subscriptionData[queryID] = { + query, since, updateCallback, removeCallback, + historyComplete: false + } + + return queryID + } + + async unsubscribe(queryID) { + // Remove allocated space + delete this.subscriptionData[queryID] + + // And unsubscribe + const data = await this.request({ + type: "unsubscribe", + queryID + }) + } + + async onOpen() { + console.log("connected to the graffiti socket") + this.open = true + this.eventTarget.dispatchEvent(new Event("graffitiOpen")) + // Resubscribe to hanging queries + for (const queryID in this.subscriptionData) { + const sd = this.subscriptionData[queryID] + await this.subscribe( + sd.query, + sd.updateCallback, + sd.removeCallback, + sd.since, + queryID) + } + } + + // Adds required fields to an object. + // You should probably call this before 'update' + completeObject(object) { + // Add by/to fields + object._by = this.myID + if ('_to' in object && !Array.isArray(object._to)) { + throw new Error("_to must be an array") + } + + // Pre-generate the object's ID if it does not already exist + if (!object._id) object._id = randomString() + } + + // Utility function to get a universally unique string + // that represents a particular object + objectUUID(object) { + if (!object._id || !object._by) { + throw { + type: 'error', + content: 'the object you are trying to identify does not have an ID or owner', + object + } + } + return object._id + object._by + } + +} diff --git a/src/auth.js b/src/auth.js deleted file mode 100644 index 76cb554..0000000 --- a/src/auth.js +++ /dev/null @@ -1,118 +0,0 @@ -import { randomString, sha256 } from './utils.js' - -export default { - - async logIn(graffitiURL) { - // Generate a random client secret and state - const clientSecret = randomString() - const state = randomString() - - // The client ID is the secret's hex hash - const clientID = await sha256(clientSecret) - - // Store the client secret as a local variable - window.localStorage.setItem('graffitiClientSecret', clientSecret) - window.localStorage.setItem('graffitiClientID', clientID) - window.localStorage.setItem('graffitiAuthState', state) - - // Redirect to the login window - const loginURL = this.authURL(graffitiURL) - loginURL.searchParams.set('client_id', clientID) - loginURL.searchParams.set('redirect_uri', window.location.href) - loginURL.searchParams.set('state', state) - window.location.href = loginURL - }, - - async connect(graffitiURL) { - - // Check to see if we are already logged in - let token = window.localStorage.getItem('graffitiToken') - let myID = window.localStorage.getItem('graffitiID') - - if (!token || !myID) { - - // Check to see if we are redirecting back - const url = new URL(window.location) - if (url.searchParams.has('code')) { - - // Extract the code and state from the URL and strip it from the history - const code = url.searchParams.get('code') - const state = url.searchParams.get('state') - url.searchParams.delete('code') - url.searchParams.delete('state') - window.history.replaceState({}, '', url) - - // Get stored variables and remove them - const clientSecret = window.localStorage.getItem('graffitiClientSecret') - const clientID = window.localStorage.getItem('graffitiClientID') - const storedState = window.localStorage.getItem('graffitiAuthState') - window.localStorage.removeItem('graffitiClientSecret') - window.localStorage.removeItem('graffitiClientID') - window.localStorage.removeItem('graffitiAuthState') - - // Make sure state has been preserved - if (state != storedState) { - throw new Error("The state in local storage does not match the state sent by the server") - } - - // Construct the body of the POST - let form = new FormData() - form.append('client_id', clientID) - form.append('client_secret', clientSecret) - form.append('code', code) - - // Ask to exchange the code for a token - const tokenURL = this.authURL(graffitiURL) - tokenURL.pathname = '/token' - const response = await fetch(tokenURL, { - method: 'post', - body: form - }) - - // Make sure the response is OK - if (!response.ok) { - let reason = response.status + ": " - try { - reason += (await response.json()).detail - } catch (e) { - reason += response.statusText - } - - throw new Error(`The authorization code could not be exchanged for a token.\n\n${reason}`) - } - - // Parse out the token - const data = await response.json() - token = data.access_token - myID = data.owner_id - - // And make sure that the token is valid - if (!token) { - throw new Error(`The authorization token could not be parsed from the response.\n\n${data}`) - } - - // Store the token and ID - window.localStorage.setItem('graffitiToken', token) - window.localStorage.setItem('graffitiID', myID) - } - } - - const loggedIn = (token != null) && (myID != null) - - return { loggedIn, myID, token } - - }, - - logOut() { - window.localStorage.removeItem('graffitiToken') - window.localStorage.removeItem('graffitiID') - window.location.reload() - }, - - authURL(graffitiURL) { - const url = new URL(graffitiURL) - url.host = "auth." + url.host - return url - }, - -} diff --git a/src/socket.js b/src/socket.js deleted file mode 100644 index 06cd7c6..0000000 --- a/src/socket.js +++ /dev/null @@ -1,222 +0,0 @@ -import Auth from './auth.js' -import { randomString } from './utils.js' - -export default class { - - constructor(graffitiURL="https://graffiti.csail.mit.edu") { - this.graffitiURL = graffitiURL - this.open = false - this.subscriptionData = {} - this.eventTarget = new EventTarget() - } - - // CALL THIS BEFORE DOING ANYTHING ELSE - async initialize() { - // Perform authorization - this.authParams = await Auth.connect(this.graffitiURL) - - // Rewrite the URL - this.wsURL = new URL(this.graffitiURL) - this.wsURL.host = "app." + this.wsURL.host - if (this.wsURL.protocol == 'https:') { - this.wsURL.protocol = 'wss:' - } else { - this.wsURL.protocol = 'ws:' - } - if (this.authParams.token) { - this.wsURL.searchParams.set("token", this.authParams.token) - } - - // And commence connection - this.connect() - } - - connect() { - this.ws = new WebSocket(this.wsURL) - this.ws.onmessage = this.onMessage.bind(this) - this.ws.onclose = this.onClose.bind(this) - this.ws.onopen = this.onOpen.bind(this) - } - - // authorization functions - logIn() { Auth.logIn(this.graffitiURL) } - logOut() { Auth.logOut() } - get myID() { return this.authParams.myID } - get loggedIn() { return this.authParams.loggedIn } - - async onClose() { - this.open = false - console.error("lost connection to graffiti server, attemping reconnect soon...") - await new Promise(resolve => setTimeout(resolve, 2000)) - this.connect() - } - - async request(msg) { - // Create a random message ID - const messageID = randomString() - - // Create a listener for the reply - const dataPromise = new Promise(resolve => { - this.eventTarget.addEventListener(messageID, (e) => { - resolve(e.data) - }) - }) - - // Wait for the socket to open - if (!this.open) { - await new Promise(resolve => { - this.eventTarget.addEventListener("graffitiOpen", () => resolve() ) - }) - } - - // Send the request - msg.messageID = messageID - this.ws.send(JSON.stringify(msg)) - - // Await the reply - const data = await dataPromise - delete data.messageID - - if (data.type == 'error') { - throw data - } else { - return data - } - } - - onMessage(event) { - const data = JSON.parse(event.data) - - if ('messageID' in data) { - // It's a reply - // Forward it back to the sender - const messageEvent = new Event(data.messageID) - messageEvent.data = data - this.eventTarget.dispatchEvent(messageEvent) - - } else if (['updates', 'removes'].includes(data.type)) { - // Subscription data - if (data.queryID in this.subscriptionData) { - const sd = this.subscriptionData[data.queryID] - - // For each data point, either add or remove it - for (const r of data.results) { - if (data.type == 'updates') { - sd.updateCallback(r) - } else { - sd.removeCallback(r) - } - } - - // And update this query's notion of "now" - if (data.complete) { - if (data.historical) { - sd.historyComplete = true - } - if (sd.historyComplete) { - sd.since = data.now - } - } - } - } else if (data.type == 'error') { - if (data.reason == 'authorization') { - this.logOut() - } - throw data - } - } - - async update(object, query) { - const data = await this.request({ - type: "update", - object, query - }) - return data.objectID - } - - async remove(objectID) { - await this.request({ - type: "remove", - objectID - }) - } - - async subscribe( - query, - updateCallback, - removeCallback, - since=null, - queryID=null) { - - // Create a random query ID - if (!queryID) queryID = randomString() - - // Send the request - await this.request({ - type: "subscribe", - queryID, query, since - }) - - // Store the subscription in case of disconnections - this.subscriptionData[queryID] = { - query, since, updateCallback, removeCallback, - historyComplete: false - } - - return queryID - } - - async unsubscribe(queryID) { - // Remove allocated space - delete this.subscriptionData[queryID] - - // And unsubscribe - const data = await this.request({ - type: "unsubscribe", - queryID - }) - } - - async onOpen() { - console.log("connected to the graffiti socket") - this.open = true - this.eventTarget.dispatchEvent(new Event("graffitiOpen")) - // Resubscribe to hanging queries - for (const queryID in this.subscriptionData) { - const sd = this.subscriptionData[queryID] - await this.subscribe( - sd.query, - sd.updateCallback, - sd.removeCallback, - sd.since, - queryID) - } - } - - // Adds required fields to an object. - // You should probably call this before 'update' - completeObject(object) { - // Add by/to fields - object._by = this.myID - if ('_to' in object && !Array.isArray(object._to)) { - throw new Error("_to must be an array") - } - - // Pre-generate the object's ID if it does not already exist - if (!object._id) object._id = randomString() - } - - // Utility function to get a universally unique string - // that represents a particular object - objectUUID(object) { - if (!object._id || !object._by) { - throw { - type: 'error', - content: 'the object you are trying to identify does not have an ID or owner', - object - } - } - return object._id + object._by - } - -} diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index ed21d46..0000000 --- a/src/utils.js +++ /dev/null @@ -1,11 +0,0 @@ -export function randomString() { - return Math.random().toString(36).substr(2) -} - -export async function sha256(input) { - const encoder = new TextEncoder() - const inputBytes = encoder.encode(input) - const outputBuffer = await crypto.subtle.digest('SHA-256', inputBytes) - const outputArray = Array.from(new Uint8Array(outputBuffer)) - return outputArray.map(b => b.toString(16).padStart(2, '0')).join('') -} diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..ed21d46 --- /dev/null +++ b/utils.js @@ -0,0 +1,11 @@ +export function randomString() { + return Math.random().toString(36).substr(2) +} + +export async function sha256(input) { + const encoder = new TextEncoder() + const inputBytes = encoder.encode(input) + const outputBuffer = await crypto.subtle.digest('SHA-256', inputBytes) + const outputArray = Array.from(new Uint8Array(outputBuffer)) + return outputArray.map(b => b.toString(16).padStart(2, '0')).join('') +} -- cgit v1.2.3-70-g09d2