diff --git a/pages/base.html b/pages/base.html index 8a1b220..d196b1a 100644 --- a/pages/base.html +++ b/pages/base.html @@ -23,6 +23,7 @@ along with this program. If not, see . --> + @@ -36,11 +37,11 @@ along with this program. If not, see . --> log in +
+
{{ template "content" .}} -
- -
+ diff --git a/pages/index.html b/pages/index.html index b7f994d..43dabf2 100644 --- a/pages/index.html +++ b/pages/index.html @@ -6,11 +6,12 @@ logged in as

-

+
- + +
diff --git a/pages/login.html b/pages/login.html index 94d6c14..7dc6f2b 100644 --- a/pages/login.html +++ b/pages/login.html @@ -20,7 +20,6 @@

- {{ end }} \ No newline at end of file diff --git a/pages/register.html b/pages/register.html index 97b5b3b..75da413 100644 --- a/pages/register.html +++ b/pages/register.html @@ -20,7 +20,6 @@

- {{ end }} \ No newline at end of file diff --git a/scripts/chat.js b/scripts/chat.js index b0cabd5..4db3c45 100644 --- a/scripts/chat.js +++ b/scripts/chat.js @@ -17,16 +17,27 @@ along with this program. If not, see . let username = localStorage.getItem(LOCALSTORAGE_NAME_KEY); let secret_hash = localStorage.getItem(LOCALSTORAGE_PASSWORD_HASH_KEY); + +let chatbox = document.getElementById("chatbox"); +let text_input = document.getElementById("text_input"); +let error_output = document.getElementById("error_output"); +let send_button = document.getElementById("send_button"); +let disconnect_button = document.getElementById("disconnect_button"); + + // check whether credentials are right or not areCredentialsValid({username:username, secret_hash:secret_hash}) .then(valid => { - if (valid === true) { + if (valid === false) { + // credentials are not valid ! To the registration page you go + window.location.replace("/register"); + } else { // they are valid, connect as a websocket document.getElementById("logged_username").innerHTML = username; - let socket = new WebSocket(`ws://${BACKEND_URL}/${API_WEBSOCKET_ENDPOINT}`); + let socket = new WebSocket(`ws://${HOST}/${API_WEBSOCKET_ENDPOINT}`); // send auth data right away, as API tells us to do - socket.onopen = event => { + socket.onopen = (_) => { console.log("Connected to the server"); let auth_data = JSON.stringify({ username: username, @@ -35,62 +46,125 @@ areCredentialsValid({username:username, secret_hash:secret_hash}) socket.send(auth_data); } - socket.onclose = event => { + // notify the user if the socket has been closed + socket.onclose = (event) => { console.log("Closed connection: ", event); socket.close(); - let chatbox = document.getElementById("chatbox"); chatbox.innerHTML += "Connection closed"; chatbox.scrollTop = chatbox.scrollHeight; }; - // display it + // display messages socket.onmessage = event => { let message = JSON.parse(event.data); let from_user = message.from.username; let date = new Date(message.timestamp).toLocaleString(); - let constructed_message_to_display = `[${date}] ${from_user}: ${message.contents}` + "\n"; - let chatbox = document.getElementById("chatbox"); + let constructed_message_to_display; + + // if message starts with ">" - display greentext + if (String(message.contents).startsWith(">")) { + constructed_message_to_display = + `

[${date}] ${from_user}: ${message.contents}

`; + } else { + constructed_message_to_display = + `

[${date}] ${from_user}: ${message.contents}

`; + } + chatbox.innerHTML += constructed_message_to_display; chatbox.scrollTop = chatbox.scrollHeight; } // make buttons do intended - let send_button = document.getElementById("send_button"); send_button.addEventListener("click", (event) => { - let text_input = document.getElementById("text_input"); + // clear previous error message + error_output.innerHTML = ""; - socket.send( - JSON.stringify({ - contents: String(text_input.value), - })); + let file_input = document.getElementById("file_input"); + let file = file_input.files[0]; + // no attachment + if (file == undefined || file == null) { + // just send a usual message + socket.send( + JSON.stringify({ + contents: String(text_input.value), + })); + + text_input.value = ""; + text_input.focus(); + return; + } + + // file "probably exists and is real" + if (file.size >= 1 && file.name.length >= 1) { + // try to send it as attachment + let formdata = new FormData(); + formdata.set(ATTACHMENT_FORM_KEY, file); + + let response_status; + fetch(API_ATTACHMENT_ENDPOINT, { + method: "POST", + headers: {"AUTH_INFO": username+":"+secret_hash}, + body: formdata + }) + .then(response => { + response_status = response.status; + return response.text(); + }) + .then(response_text => { + if (response_status != 200) { + error_output.innerHTML = "Error uploading file: " + response_text; + } else { + // no errors, attachment is on the server, append link to it and send message + let response_json = JSON.parse(response_text); + + let attachment_url; + if (is_image(file.name) === true) { + // it's an image so let's display it right away + attachment_url = + ``; + } else { + // just link to it + attachment_url = + `${file.name}`; + } - text_input.value = ""; - text_input.focus(); + + socket.send( + JSON.stringify({ + contents: `${text_input.value} ${attachment_url}`, + })); + + text_input.value = ""; + file_input.value = null; + text_input.focus(); + } + }) + .catch(error => { + error_output.innerHTML = "Error sending attachment: " + error; + file_input.value = null; + }); + } }); - let disconnect_button = document.getElementById("disconnect_button"); - disconnect_button.addEventListener("click", (event) => { + // disconnect/reconnect button + disconnect_button.addEventListener("click", (_) => { socket.close(); disconnect_button.value = "Reconnect"; - disconnect_button.addEventListener("click", (event) => { - location.reload(); + disconnect_button.addEventListener("click", (_) => { + location.reload(); }) - }) + }); // make message to be sent via pressing 'Enter' key document.addEventListener("keypress", (event) => { if (event.key == "Enter") { send_button.click(); } - }) + }); // and focus the text input so the user does not have to click it manually document.getElementById("text_input").focus(); - - } else { - // credentials are not valid ! To the registration page you go - window.location.replace("/register"); } -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/scripts/constants.js b/scripts/constants.js index 3a2b25e..5948847 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -14,9 +14,9 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -const API_BASE_ENDPOINT = "api"; const API_USER_ENDPOINT = "api/user" const API_WEBSOCKET_ENDPOINT = "api/ws"; +const API_ATTACHMENT_ENDPOINT = "api/attachment" const AUTH_HEADER_KEY = "AUTH_INFO"; const AUTH_SEPARATOR = ":" @@ -24,4 +24,6 @@ const AUTH_SEPARATOR = ":" const LOCALSTORAGE_NAME_KEY = "username"; const LOCALSTORAGE_PASSWORD_HASH_KEY = "secret_hash"; -const BACKEND_URL = window.location.host; \ No newline at end of file +const ATTACHMENT_FORM_KEY = "attachment"; + +const HOST = window.location.host; \ No newline at end of file diff --git a/scripts/to_hex_string.js b/scripts/to_hex_string.js deleted file mode 100644 index f1e4056..0000000 --- a/scripts/to_hex_string.js +++ /dev/null @@ -1,22 +0,0 @@ -/* -gochat - A dead simple real time webchat. -Copyright (C) 2022 Kasyanov Nikolay Alexeyevich (Unbewohnte) -This file is a part of gochat -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. -You should have received a copy of the GNU General Public License -along with this program. If not, see . -*/ - - -function to_hex_string(byteArr) { - return Array.from(byteArr, function(byte) { - return ('0' + (byte & 0xFF).toString(16).slice(-2)); - }).join('') -} \ No newline at end of file diff --git a/scripts/sha256.js b/scripts/util.js similarity index 71% rename from scripts/sha256.js rename to scripts/util.js index 1c57190..80507c6 100644 --- a/scripts/sha256.js +++ b/scripts/util.js @@ -14,6 +14,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ + function sha256(string) { const encoded_string = new TextEncoder().encode(string); return crypto.subtle.digest('SHA-256', encoded_string).then((hash_sum) => { @@ -23,4 +24,26 @@ function sha256(string) { .join(''); return hashHex; }); - } \ No newline at end of file +} + + +function to_hex_string(byteArr) { + return Array.from(byteArr, function(byte) { + return ('0' + (byte & 0xFF).toString(16).slice(-2)); + }).join('') +} + +function is_image(filename) { + image_exts = ["jpe", "jpeg", "jpg", "png", "ppm", "gif"] + + let is_img = false; + + image_exts.find(ext => { + if (filename.includes(ext)) { + is_img = true; + return true; + } + }); + + return is_img; +} \ No newline at end of file diff --git a/src/api/attachment.go b/src/api/attachment.go new file mode 100644 index 0000000..40aba2d --- /dev/null +++ b/src/api/attachment.go @@ -0,0 +1,7 @@ +package api + +const AttachmentFormPostKey string = "attachment" + +type PartialAttachmentURL struct { + URL string `json:"url"` +} diff --git a/src/api/limits.go b/src/api/limits.go index 09f2831..b559355 100644 --- a/src/api/limits.go +++ b/src/api/limits.go @@ -5,3 +5,6 @@ const MaxMessageContentLen uint = 500 // How many messages can be "remembered" until removal const MaxMessagesRemembered uint = 50 + +// Max filesize to accept as attachment +const MaxAttachmentSize uint64 = 31457280 // 30MB diff --git a/src/api/message.go b/src/api/message.go index 02e1a48..c17850f 100644 --- a/src/api/message.go +++ b/src/api/message.go @@ -56,7 +56,8 @@ func (db *DB) AddMessage(message Message) error { } } - command = fmt.Sprintf("INSERT INTO %s(sender, content, timestamp) VALUES(\"%s\", \"%s\", %d)", MessagesTablename, message.From.Name, message.Contents, message.TimeStamp) + command = fmt.Sprintf("INSERT INTO %s(sender, content, timestamp) VALUES('%s', '%s', %d)", + MessagesTablename, message.From.Name, message.Contents, message.TimeStamp) _, err = db.Exec(command) if err != nil { return err diff --git a/src/api/routes.go b/src/api/routes.go index 728edb2..07ece4f 100644 --- a/src/api/routes.go +++ b/src/api/routes.go @@ -17,8 +17,8 @@ along with this program. If not, see . package api const ( - RouteBase string = "/api" - RouteUsers string = RouteBase + "/user" - RouteWebsockets string = RouteBase + "/ws" - RouteRooms string = RouteBase + "/room" + RouteBase string = "/api" + RouteUsers string = RouteBase + "/user" + RouteWebsockets string = RouteBase + "/ws" + RoutePostAttachemnts string = RouteBase + "/attachment" ) diff --git a/src/server/attachmentHandler.go b/src/server/attachmentHandler.go new file mode 100644 index 0000000..2598ab2 --- /dev/null +++ b/src/server/attachmentHandler.go @@ -0,0 +1,115 @@ +package server + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "unbewohnte.xyz/Unbewohnte/gochat/api" + "unbewohnte.xyz/Unbewohnte/gochat/log" +) + +// Handle incoming attachments +func (s *Server) handleAttachments(w http.ResponseWriter, req *http.Request) { + userAuthHeader := api.GetUserAuthHeaderData(req) + if !s.db.DoesUserExist(userAuthHeader.Name) { + http.Error(w, "Not authorized", http.StatusUnauthorized) + return + } + + userDB, _ := s.db.GetUser(userAuthHeader.Name) + if userDB.SecretHash != userAuthHeader.SecretHash { + http.Error(w, "Not authorized", http.StatusUnauthorized) + return + } + + // accept incoming file + file, header, err := req.FormFile(api.AttachmentFormPostKey) + if err != nil { + log.Error("could not get attached file: %s", err) + http.Error(w, "Error getting attached file", http.StatusInternalServerError) + return + } + defer file.Close() + + if uint64(header.Size) > api.MaxAttachmentSize { + http.Error(w, "Too big file", http.StatusBadRequest) + return + } + + localFilename := fmt.Sprintf("%s_%d_%s", userDB.Name, header.Size, header.Filename) + localFile, err := os.Create(filepath.Join(s.workingDir, attachmentsDirName, localFilename)) + if err != nil { + log.Error("could not create local attachment file: %s", err) + http.Error(w, "Could not create file", http.StatusInternalServerError) + return + } + defer localFile.Close() + + _, err = io.Copy(localFile, file) + if err != nil { + log.Error("could not copy attachment file contents: %s", err) + http.Error(w, "Could not copy file contents", http.StatusInternalServerError) + return + } + + // send partial URL pointing to the file + url := api.PartialAttachmentURL{ + URL: fmt.Sprintf("%s/%s", attachmentsDirName, localFilename), + } + urlJsonBytes, err := json.Marshal(&url) + if err != nil { + log.Error("could not marshal partial attachment URL for \"%s\": %s", localFilename, err) + http.Error(w, "Error constructing a request", http.StatusInternalServerError) + return + } + + w.Write(urlJsonBytes) +} + +// Remove the oldest attachments when the memory limit is exceeded +func manageAttachmentsStorage(attachmentsDirPath string, sizeLimit uint64, checkDelay time.Duration) { + for { + dirEntries, err := os.ReadDir(attachmentsDirPath) + if err != nil { + log.Error("error reading attachments directory: %s", err) + } + + var dirSize uint64 = 0 + var oldestAttachmentModTime time.Time = time.Now() + var oldestAttachmentPath string = "" + var oldestAttachmentSize uint64 = 0 + if dirEntries != nil { + for _, entry := range dirEntries { + entryInfo, err := entry.Info() + if err != nil { + continue + } + + entrySize := entryInfo.Size() + dirSize += uint64(entrySize) + + entryModTime := entryInfo.ModTime() + if entryModTime.Before(oldestAttachmentModTime) { + oldestAttachmentModTime = entryModTime + oldestAttachmentPath = filepath.Join(attachmentsDirPath, entry.Name()) + oldestAttachmentSize = uint64(entrySize) + } + } + + if dirSize > sizeLimit { + // A cleanup ! + os.Remove(oldestAttachmentPath) + log.Info( + "removed %s during attachments storage management. Cleared %d bytes", + oldestAttachmentPath, oldestAttachmentSize) + } + } + + time.Sleep(checkDelay) + } +} diff --git a/src/server/server.go b/src/server/server.go index 8569737..b30ec95 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -38,6 +38,13 @@ type Server struct { incomingMessages chan api.Message } +const ( + pagesDirName string = "pages" + staticDirName string = "static" + scriptsDirName string = "scripts" + attachmentsDirName string = "attachments" +) + // Create a new configured and ready-to-launch server func New(workingDir string, dbPath string, port uint) (*Server, error) { var server = Server{ @@ -60,11 +67,12 @@ func New(workingDir string, dbPath string, port uint) (*Server, error) { server.db = db // set up routes and handlers - const ( - pagesDirName string = "pages" - staticDirName string = "static" - scriptsDirName string = "scripts" - ) + attachmentsDirPath := filepath.Join(workingDir, attachmentsDirName) + err = os.MkdirAll(attachmentsDirPath, os.ModePerm) + if err != nil { + log.Error("could not create attachments directory: %s", err) + os.Exit(1) + } pagesDirPath := filepath.Join(workingDir, pagesDirName) @@ -72,6 +80,7 @@ func New(workingDir string, dbPath string, port uint) (*Server, error) { serveMux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(workingDir, staticDirName))))) serveMux.Handle("/scripts/", http.StripPrefix("/scripts/", http.FileServer(http.Dir(filepath.Join(workingDir, scriptsDirName))))) + serveMux.Handle("/attachments/", http.StripPrefix("/attachments/", http.FileServer(http.Dir(attachmentsDirPath)))) serveMux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { switch req.URL.Path { @@ -86,6 +95,9 @@ func New(workingDir string, dbPath string, port uint) (*Server, error) { default: if strings.HasPrefix(req.URL.Path, api.RouteBase) { return + } else if strings.Contains(req.URL.Path, "favicon.ico") { + // remove that annoying favicon error by simply ingoring the thing + return } requestedPage, err := page.Get(pagesDirPath, "base.html", req.URL.Path[1:]+".html") @@ -101,6 +113,8 @@ func New(workingDir string, dbPath string, port uint) (*Server, error) { serveMux.HandleFunc(api.RouteUsers, server.HandlerUsers) // ws api endpoint serveMux.HandleFunc(api.RouteWebsockets, server.HandlerWebsockets) + // attachments handler + serveMux.HandleFunc(api.RoutePostAttachemnts, server.handleAttachments) httpServer := http.Server{ Addr: fmt.Sprintf(":%d", port), @@ -116,8 +130,11 @@ func New(workingDir string, dbPath string, port uint) (*Server, error) { // Fire up the server func (s *Server) Start() { defer s.db.ShutDown() + // broadcast messages go s.BroadcastMessages() - + // clean attachments storage from time to time + // max attachment filesize * 50 is the limit, check every 5 sec + go manageAttachmentsStorage(filepath.Join(s.workingDir, attachmentsDirName), api.MaxAttachmentSize*50, time.Second*5) // fire up a server err := s.http.ListenAndServe() if err != nil { diff --git a/static/style.css b/static/style.css index fd684f9..2b2b7fc 100644 --- a/static/style.css +++ b/static/style.css @@ -33,6 +33,18 @@ nav { display: inline-block; } +.chat-image { + max-width: 40%; + max-height: 80%; + + min-width: 12ch; + min-height: 16ch; +} + +.greentext { + color: green; +} + #wrapper { display: flex; flex-flow: column wrap;