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;