summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortheia2022-07-31 13:41:31 -0400
committertheia2022-07-31 13:41:31 -0400
commit0218c723ffb7b1c7a52acc8d0fa4d0702fd56ec5 (patch)
tree9de911667eb7dd7ecc8f4c9a80126cad312c0e12
moved out of vue plugin
-rw-r--r--README.md54
-rw-r--r--src/auth.js113
-rw-r--r--src/socket.js217
-rw-r--r--src/utils.js11
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('')
+}