Browse Source

Greentext, Attachments, Automatic attachment directory management

master
Gitea 2 years ago
parent
commit
f45c2ecb27
  1. 7
      pages/base.html
  2. 5
      pages/index.html
  3. 1
      pages/login.html
  4. 1
      pages/register.html
  5. 128
      scripts/chat.js
  6. 6
      scripts/constants.js
  7. 22
      scripts/to_hex_string.js
  8. 25
      scripts/util.js
  9. 7
      src/api/attachment.go
  10. 3
      src/api/limits.go
  11. 3
      src/api/message.go
  12. 8
      src/api/routes.go
  13. 115
      src/server/attachmentHandler.go
  14. 29
      src/server/server.go
  15. 12
      static/style.css

7
pages/base.html

@ -23,6 +23,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. -->
<link rel="stylesheet" href="/static/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="/scripts/constants.js"></script>
<script src="/scripts/util.js"></script>
</head>
<body>
@ -36,11 +37,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. -->
<a href="/login">log in</a>
</nav>
<div id="error_output"></div>
<div id="content">
{{ template "content" .}}
</div>
<footer id="error_output"></footer>
</div>
</div>
</body>

5
pages/index.html

@ -6,11 +6,12 @@
logged in as <b id="logged_username" onload=""></b>
</p>
<pre id="chatbox"></pre>
<div id="chatbox"></div>
<div>
<input type="text" id="text_input">
<input type="text" id="text_input" multiple autofocus>
<input type="button" value="Send" id="send_button">
<input type="file" id="file_input" value="Add attachment">
<input type="button" value="Disconnect" id="disconnect_button">
</div>

1
pages/login.html

@ -20,7 +20,6 @@
</p>
</form>
<script src="/scripts/sha256.js"></script>
<script src="/scripts/user.js"></script>
{{ end }}

1
pages/register.html

@ -20,7 +20,6 @@
</p>
</form>
<script src="/scripts/sha256.js"></script>
<script src="/scripts/user.js"></script>
{{ end }}

128
scripts/chat.js

@ -17,16 +17,27 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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 =
`<p><small>[${date}]</small> <b>${from_user}</b>: <i class="greentext">${message.contents}</i></p>`;
} else {
constructed_message_to_display =
`<p><small>[${date}]</small> <b>${from_user}</b>: ${message.contents}</p>`;
}
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 =
`<a href="${response_json.url}" target="_blank" rel="noopener noreferrer"><img class="chat-image" src="${response_json.url}"></a>`;
} else {
// just link to it
attachment_url =
`<a href="${response_json.url}" target="_blank" rel="noopener noreferrer">${file.name}</a>`;
}
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");
}
})
});

6
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 <https://www.gnu.org/licenses/>.
*/
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;
const ATTACHMENT_FORM_KEY = "attachment";
const HOST = window.location.host;

22
scripts/to_hex_string.js

@ -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 <https://www.gnu.org/licenses/>.
*/
function to_hex_string(byteArr) {
return Array.from(byteArr, function(byte) {
return ('0' + (byte & 0xFF).toString(16).slice(-2));
}).join('')
}

25
scripts/sha256.js → 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 <https://www.gnu.org/licenses/>.
*/
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;
});
}
}
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;
}

7
src/api/attachment.go

@ -0,0 +1,7 @@
package api
const AttachmentFormPostKey string = "attachment"
type PartialAttachmentURL struct {
URL string `json:"url"`
}

3
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

3
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

8
src/api/routes.go

@ -17,8 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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"
)

115
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)
}
}

29
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 {

12
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;

Loading…
Cancel
Save