From 0a1bff5cd44a0b95df4869a838a1bf6633a7c782 Mon Sep 17 00:00:00 2001 From: Kato Twofold Date: Tue, 7 Jun 2022 02:26:55 +0300 Subject: [PATCH] * Branch reset --- go.mod | 1 + go.sum | 2 + lib/socketmanager/socketmanager.go | 145 ++++++++++++++++--- static/css/main.css | 26 ++++ static/js/pad-scripts.js | 7 +- static/js/ws.js | 214 +++++++++++++++++++++++++++-- templates/pages/page.html | 11 +- 7 files changed, 372 insertions(+), 34 deletions(-) diff --git a/go.mod b/go.mod index 43ef10f..28bb13b 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.15 require ( github.com/gin-gonic/gin v1.7.7 + github.com/google/uuid v1.3.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/joho/godotenv v1.4.0 github.com/mrz1836/go-sanitize v1.1.5 diff --git a/go.sum b/go.sum index 38f7d7f..149ae52 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= diff --git a/lib/socketmanager/socketmanager.go b/lib/socketmanager/socketmanager.go index 219ad37..31ffcbe 100644 --- a/lib/socketmanager/socketmanager.go +++ b/lib/socketmanager/socketmanager.go @@ -4,17 +4,22 @@ import ( "encoding/json" "errors" "fmt" - "net/http" + "github.com/JustKato/FreePad/lib/objects" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/gorilla/websocket" ) var wsUpgrader = websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, + ReadBufferSize: 1024, // TODO: Make it configurable via the .env file + WriteBufferSize: 1024, // TODO: Make it configurable via the .env file } +// The pad socket map caches all of the existing sockets +var padSocketMap map[string]map[string]*websocket.Conn = make(map[string]map[string]*websocket.Conn) + +// TODO: Use generics so that we can take string messages, that'd be nice! type SocketMessage struct { EventType string `json:"eventType"` PadName string `json:"padName"` @@ -24,32 +29,55 @@ type SocketMessage struct { // Bind the websockets to the gin router func BindSocket(router *gin.RouterGroup) { - router.GET("/get", func(ctx *gin.Context) { - webSocketUpgrade(ctx.Writer, ctx.Request) + router.GET("/get/:pad", func(ctx *gin.Context) { + // Get the name of the pad to assign to this socket + padName := ctx.Param("pad") + // Upgrade the socket connection + webSocketUpgrade(ctx, padName) }) } -func webSocketUpgrade(w http.ResponseWriter, r *http.Request) { - conn, err := wsUpgrader.Upgrade(w, r, nil) +func webSocketUpgrade(ctx *gin.Context, padName string) { + + conn, err := wsUpgrader.Upgrade(ctx.Writer, ctx.Request, ctx.Request.Header) if err != nil { fmt.Printf("Failed to set websocket upgrade: %v\n", err) return } + // Check if we have any sockets in this padName + if _, ok := padSocketMap[padName]; !ok { + // Initialize a new map of sockets + padSocketMap[padName] = make(map[string]*websocket.Conn) + } + + // Give this socket a token + socketToken := uuid.NewString() + + // Set the current connection at the socket Token position + padSocketMap[padName][socketToken] = conn + + // Somone just connected + UpdatePadStatus(padName) + // Start listening to this socket for { // Try Read the JSON input from the socket _, msg, err := conn.ReadMessage() - // Check if a close request was sent - if errors.Is(err, websocket.ErrCloseSent) { + // Check if anything but a read limit was created + if err != nil && !errors.Is(err, websocket.ErrReadLimit) { + // Remove self from the cache + delete(padSocketMap[padName], socketToken) + // Somone just disconnected + UpdatePadStatus(padName) break } if err != nil { // There has been an error reading the message - fmt.Println("Failed to read from the socket") + fmt.Println("Failed to read from the socket but probably still connected") // Skip this cycle continue } @@ -66,19 +94,104 @@ func webSocketUpgrade(w http.ResponseWriter, r *http.Request) { } // Pass the message to the proper handlers - - handleSocketMessage(p) + handleSocketMessage(p, socketToken, padName) } } // Handle the socket's message -func handleSocketMessage(msg SocketMessage) { +func handleSocketMessage(msg SocketMessage, socketToken string, padName string) { - // Check the type of message - fmt.Println(msg.EventType) + // Check if this is a pad Update + if msg.EventType == `padUpdate` { + handlePadUpdate(msg, socketToken, padName) + + // Serialize the message + serialized, err := json.Marshal(msg) + // Check if there was an error + if err != nil { + fmt.Println(`Failed to broadcast the padUpdate`, err) + // Stop the execution + return + } + + // Alert all the other pads other than this one. + for k, pad := range padSocketMap[padName] { + // Check if this is the same socket. + if k == socketToken { + // Skip self + continue + } + + // Send the message to the others. + pad.WriteMessage(websocket.TextMessage, serialized) + } + } } -func BroadcastMessage(padName string, message string) { +func handlePadUpdate(msg SocketMessage, socketToken string, padName string) { + + // Check if the msg content is valid + if _, ok := msg.Message[`content`]; !ok { + fmt.Printf("Failed to update pad %s, invalid message\n", padName) + return + } + + // Check that the content is string + newPadContent, ok := msg.Message[`content`].(string) + if !ok { + fmt.Printf("Type assertion failed for %s, invalid message\n", padName) + return + } + + // Get the pad + pad := objects.GetPost(padName, false) + // Update the pad contents + pad.Content = newPadContent + + // Save to file + objects.WritePost(pad) +} + +// Update the current users of the pad about the amount of live viewers. +func UpdatePadStatus(padName string) { + + // Grab info about the map's key + sockets, ok := padSocketMap[padName] + // Check if the pad is set and has sockets connected. + if !ok || len(sockets) < 1 { + // Quit + return + } + + // Generate the message + msg := SocketMessage{ + EventType: `statusUpdate`, + PadName: padName, + Message: gin.H{ + // Send the current amount of live viewers + "currentViewers": len(sockets), + }, + } + + BroadcastMessage(padName, msg) + +} + +func BroadcastMessage(padName string, msg SocketMessage) { + + // Grab info about the map's key + sockets, ok := padSocketMap[padName] + // Check if the pad is set and has sockets connected. + if !ok || len(sockets) < 1 { + // Quit + return + } + + // Get all the participants of the pad group + for _, s := range sockets { + // Send the message to the socket + s.WriteJSON(msg) + } } diff --git a/static/css/main.css b/static/css/main.css index 9940596..81e62ee 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -45,6 +45,24 @@ main#main-card { tab-size: 2; font-family: 'Roboto Mono', monospace !important; + + padding-top: 2rem; +} + +#padTitle { + padding: .3rem .75rem !important; + border-radius: .25rem; + display: inline-block; +} + +.dark #padTitle { + color: #db3384; + background-color: rgba(0, 0, 0, 0.10); +} + +.light #padTitle { + color: #555273; + border: 1px solid rgba(0, 0, 0, 0.15); } #pad-content-area { @@ -54,11 +72,16 @@ main#main-card { flex-flow: column; } +.light .edit-content-text { + color: white !important; +} + .edit-content-text { display: none; } .read-only-content .edit-content-text { + margin-top: 1rem; display: block !important; } @@ -78,6 +101,9 @@ main#main-card { max-height: calc(17rem + 30vh); min-height: 17rem; overflow: auto; + + padding-top: 2rem; + margin-top: 1rem; } textarea:focus, diff --git a/static/js/pad-scripts.js b/static/js/pad-scripts.js index 3190d98..83b945d 100644 --- a/static/js/pad-scripts.js +++ b/static/js/pad-scripts.js @@ -138,7 +138,10 @@ function renderArchivesSelection() { let resp = confirm("Load contents of pad from memory? This will overwrite the current pad for everyone."); if (!!resp) { - document.getElementById(`pad-content`).value = a.content; + // Update visually for the client + updatePadContent(a.content); + // Send the update + window.socket.sendPadUpdate(); } }) @@ -210,6 +213,8 @@ function setTextareaPreview(t = true) { padContentArea.classList.add(`read-only-content`); + prev.scrollTop = prev.scrollHeight; + textarea.classList.add(`hidden`); } else { // Toggle edit mode diff --git a/static/js/ws.js b/static/js/ws.js index f33ced3..50dc93a 100644 --- a/static/js/ws.js +++ b/static/js/ws.js @@ -1,8 +1,24 @@ class PadSocket { + /** + * @type {WebSocket} + */ ws = null; + /** + * @type {String} + */ padName = null; - state = null; + + /** + * The actual textarea you write in + * @type {HTMLTextAreaElement} + */ + padContents = null; + /** + * The of the preview + * @type {HTMLElement} + */ + padPreview = null; /** * Create a new PadSocket @@ -10,26 +26,45 @@ class PadSocket { * @param {string} connUrl The URL to the websocket */ constructor(padName, connUrl = null) { + // Assign the pad name + this.padName = padName; // Check if a connection URL was mentioned if ( connUrl == null ) { + + let connProtocol = `ws://`; + if ( window.location.protocol == `https:` ) { + connProtocol = `wss://`; + } + // Try and connect to the local websocket - connUrl = `ws://` + window.location.host + "/ws/get"; + connUrl = connProtocol + window.location.host + `/ws/get/${padName}`; } // Connect to the websocket const ws = new WebSocket(connUrl); - ws.onopen = () => { - this.state = 'active'; - } // Bind the onMessage function ws.onmessage = this.handleMessage; + ws.onopen = () => { + updateStatus(`Established`, `text-success`); + } + + function onFail() { + updateStatus(`Connection Failed`, `text-dangerous`); + } + + // Try and reconnect on failure + ws.onclose = onFail; + ws.onerror = onFail; + // Assign the websocket this.ws = ws; - // Assign the pad name - this.padName = padName; + + // Get all relevant references from the HTML + this.padContents = document.getElementById(`pad-content`); + this.padPreview = document.getElementById(`textarea-preview`); } /** @@ -39,18 +74,19 @@ class PadSocket { */ sendMessage = (eventType, message) => { - if ( this.state != 'active' ) { + if ( this.ws.readyState !== WebSocket.OPEN ) { throw new Error(`The websocket connection is not active`); } // Check if the message is a string - if ( typeof message == 'string' ) { + if ( typeof message !== 'object' ) { // Convert the message into a map[string]interface{} message = { "message": message, }; } + // TODO: Compress the message, usually we will be sending the whole body of the pad from the client to the server or vice-versa. this.ws.send( JSON.stringify({ eventType, padName: this.padName, @@ -59,13 +95,167 @@ class PadSocket { } + /** + * Handle the message from the socket based on the message type + * @param {MessageEvent} e The websocket message + */ handleMessage = ev => { - console.log(ev); + updateStatus(`Catching Message`, `text-white`); + + // Check if the message has valid data + if ( !!ev.data ) { + // Try and parse the data + let parsedData = null; + + try { + parsedData = JSON.parse(ev.data); + } catch ( err ) { + console.error(`Failed to parse the WebSocket data`,err); + updateStatus(`Parse Fail`, `text-warning`); + } + + if ( !!!parsedData['message'] ) { + console.error(`Failed to find the message`) + updateStatus(`Message Fail`, `text-warning`); + return; + } + + // Check if this is a pad Content Update + if ( parsedData['eventType'] === `padUpdate`) { + // Pass on the parsed data + this.onPadUpdate(parsedData); + } // Check if this is a pad Status Update + else if ( parsedData['eventType'] === `statusUpdate`) { + // Pass on the parsed data + this.onStatusUpdate(parsedData); + } + + updateStatus(`Established`, `text-success`); + } + } + + /** + * Whenever a pad update is trigered, run this function + * @param {Object} The response from the server + */ + onPadUpdate = data => { + // Check that the content is clear + if ( !!data['message']['content'] ) { + // Send over the new content to be updated. + updatePadContent(data['message']['content']); + } + } + + onStatusUpdate = data => { + // Check that the content is clear + if ( !!data['message']['currentViewers'] ) { + // Get the amount of viewers reported by the server + const viewerCount = Number(data['message']['currentViewers']); + // Check if this is a valid number + if ( Number.isNaN(viewerCount) ) { + // Looks like this is a malformed message + return console.error(`Malformed Message`, data); + } + + // Send over the new content to be updated. + updatePadViewers(viewerCount); + } + } + + /** + * Sending a pad update for each keystroke to the server. + * @param {String} msg The new contents of the pad + */ + sendPadUpdate = msg => { + // Get the contents of the pad + const padContents = this.padContents.value; + + // Send the data over the webSocket + this.sendMessage(`padUpdate`, { + "content": padContents, + }); + + updatePadContent(padContents, false); } } +/** + * Update the contents of the pad + * @param {String} newContent + */ +function updatePadContent(newContent, textArea = true) { + // Update the textarea + if ( textArea ) { + document.getElementById(`pad-content`).value = newContent; + } + + // Update the preview + const prev = document.getElementById(`textarea-preview`); + const shouldScroll = prev.scrollTop >= (prev.scrollHeight - Number(getComputedStyle(prev).height.replace(/px/g, ''))) * 0.98; + + prev.innerHTML = escapeHtml(newContent); + + prev.classList.remove(`language-undefined`); + + prev.classList.forEach( c => { + if ( c.indexOf(`language-`) != -1 ) { + prev.classList.remove(c); + } + }) + + try { // highlights + hljs.highlightElement(document.getElementById(`textarea-preview`)); + } catch ( err ) { + console.err(err); + } + + // Check if we should follow the bottom scrolling + if (shouldScroll) { + prev.scrollTop = prev.scrollHeight; + } + +} + +function updatePadViewers(vc) { + // Get the reference to the viewers count inputElement + /** + * @type {HTMLInputElement} + */ + const viewerCount = document.getElementById(`currentViewers`); + + // Get the amount of total viewers + const totalViews = viewerCount.value.split("|")[1].trim(); + + // Set back the real value + viewerCount.value = `${vc} | ${totalViews}`; +} + +function connectSocket() { + // Check if the socket is established + if ( !!!window.socket || window.socket.readyState !== WebSocket.OPEN ) { + updateStatus(`Connecting...`, `text-warning`); + // Connect the socket + window.socket = new PadSocket(padTitle); + } + +} + +// TODO: Test if this is actually necessary or the DOMContentLoaded event would suffice // wait for the whole window to load window.addEventListener(`load`, e => { - window.socket = new PadSocket(padTitle); -}) \ No newline at end of file + connectSocket() +}) + + +// lol +function escapeHtml(html){ + const text = document.createTextNode(html); + const p = document.createElement('p'); + + p.appendChild(text); + const content = p.innerHTML; + p.remove(); + + return content; + } \ No newline at end of file diff --git a/templates/pages/page.html b/templates/pages/page.html index 7870dcf..81e762a 100644 --- a/templates/pages/page.html +++ b/templates/pages/page.html @@ -35,7 +35,9 @@ -

{{.title}}

+

+ {{.title}} +

@@ -53,8 +55,7 @@
-
@@ -73,7 +74,7 @@
-
+
- +