diff options
author | anonymous | 2022-09-21 12:46:08 -0400 |
---|---|---|
committer | anonymous | 2022-09-21 12:46:08 -0400 |
commit | 8453493fc930c1d3579bef3e8b41639380f018fb (patch) | |
tree | 9de911667eb7dd7ecc8f4c9a80126cad312c0e12 |
moved out of vue plugin
-rw-r--r-- | README.md | 54 | ||||
-rw-r--r-- | src/auth.js | 113 | ||||
-rw-r--r-- | src/socket.js | 217 | ||||
-rw-r--r-- | src/utils.js | 11 |
4 files changed, 395 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a61e7a --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# The Basic Graffiti Javascript Library + +This is the base Javascript library that interfaces with the [Graffiti server](https://github.com/csail-graffiti/server). +We recommend not using the vanilla library itself but instead using framework plugins built on top of this library like the [Graffiti plugin for Vue.JS](https://github.com/csail-graffiti/graffiti-js-vue). + +With this library: + +```javascript +import GraffitiSocket from "https://csail-graffiti.github.io/graffiti-js-vanilla/socket.js" + +// You can initialize a connection to the graffiti server +const gs = GraffitiSocket() +await gs.initialize() + +// You can subscribe to queries +const queryID = await gs.subscribe({ + type: 'post', + content: { $type: 'string' } + } + // With an arbitrary update callback + (obj) => console.log(`An object has been created: {obj}`) + // and delete callback + (obj) => console.log(`An object with id {obj._id} by user {obj._by} has been deleted`. +) + +// And then unsubscribe to those queries +await gs.unsubscribe(queryID) + +// You can log in and out and check your logged-in status +gs.logIn() +gs.logOut() +if (gs.loggedIn) { + ... +} + +// When you are logged in you reference your user ID +console.log(gs.myID) + +// And when you are logged in you can +// create objects, +const myCoolPost = { + type: 'post', + content: 'hello world' +} +gs.complete(myCoolPost) +await gs.update(myCoolPost) + +// replace objects, +myCoolPost.content += '!!!' +await gs.update(myCoolPost) + +// and delete objects. +await gs.delete(myCoolPost) +``` diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..697c61e --- /dev/null +++ b/src/auth.js @@ -0,0 +1,113 @@ +import { randomString, sha256 } from './utils.js' + +export default { + + async logIn(origin) { + // 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 authURL = new URL(origin) + authURL.searchParams.set('client_id', clientID) + authURL.searchParams.set('redirect_uri', window.location.href) + authURL.searchParams.set('state', state) + window.location.href = authURL + }, + + async connect(origin) { + origin = new URL(origin) + origin.host = "auth." + origin.host + + // 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 = new URL('token', origin) + 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() + }, + +} diff --git a/src/socket.js b/src/socket.js new file mode 100644 index 0000000..d324b78 --- /dev/null +++ b/src/socket.js @@ -0,0 +1,217 @@ +import Auth from './auth.js' +import { randomString } from './utils.js' + +export default class { + + constructor(origin="https://graffiti.csail.mit.edu") { + this.origin = origin + 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.origin) + + // Rewrite the URL + this.wsURL = new URL(origin) + 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.origin) } + 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', 'deletes'].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.deleteCallback(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 + } + } + } + } + } + + async update(object) { + const data = await this.request({ + type: "update", + object + }) + return data.objectID + } + + async delete(objectID) { + await this.request({ + type: "delete", + objectID + }) + } + + async subscribe( + query, + updateCallback, + deleteCallback, + 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, deleteCallback, + 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.deleteCallback, + 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 new file mode 100644 index 0000000..ed21d46 --- /dev/null +++ b/src/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('') +} |