From a2adaa05a69f946c3ae96f3cc0fd25d561002d50 Mon Sep 17 00:00:00 2001 From: cdez Date: Wed, 12 Mar 2025 21:09:40 -0700 Subject: [PATCH 01/28] Add lots of functionality to the server codebase. --- chatserver/server.py | 128 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 20 deletions(-) diff --git a/chatserver/server.py b/chatserver/server.py index fd79b83..dd6bb99 100644 --- a/chatserver/server.py +++ b/chatserver/server.py @@ -1,11 +1,59 @@ import socket import select +import uuid +import pickle +import os CHATPORT = 8181 BUFFER_SIZE = 1024 MAX_CONNECT_QUEUE = 10 +DATA_FILE = 'server_data.pkl' +ADMIN_USERNAME = "admin" +ADMIN_PASSWORD = "adminpass" +UUID_FILE = 'uuids.pkl' + +def save_data(client_info, chat_rooms, client_states): + serializable_client_info = {info["user_id"]: info for sock, info in client_info.items()} + serializable_chat_rooms = {info["user_id"]: chat_rooms[sock] for sock, info in client_info.items()} + serializable_client_states = {info["user_id"]: client_states[sock] for sock, info in client_info.items()} + with open(DATA_FILE, 'wb') as f: + pickle.dump((serializable_client_info, serializable_chat_rooms, serializable_client_states), f) + +def load_data(): + if os.path.exists(DATA_FILE): + with open(DATA_FILE, 'rb') as f: + serializable_client_info, serializable_chat_rooms, serializable_client_states = pickle.load(f) + client_info = {uuid.UUID(user_id): info for user_id, info in serializable_client_info.items()} + chat_rooms = {uuid.UUID(user_id): room for user_id, room in serializable_chat_rooms.items()} + client_states = {uuid.UUID(user_id): state for user_id, state in serializable_client_states.items()} + return client_info, chat_rooms, client_states + return {}, {}, {} + +def save_uuids(uuid_dict): + with open(UUID_FILE, 'wb') as f: + pickle.dump(uuid_dict, f) + +def load_uuids(): + if os.path.exists(UUID_FILE): + with open(UUID_FILE, 'rb') as f: + return pickle.load(f) + return {} + +def get_client_uuid(client_socket, uuid_dict): + client_address = client_socket.getpeername() + client_key = f"{client_address[0]}_{client_address[1]}" + + if client_key in uuid_dict: + user_id = uuid_dict[client_key] + else: + user_id = str(uuid.uuid4()) + uuid_dict[client_key] = user_id + save_uuids(uuid_dict) + + return user_id def main(): + global quitFlag # set the flag variable to true for infinite loop quitFlag = True # create a socket object @@ -23,14 +71,9 @@ def main(): # create a list to store the sockets, server and client socket_list = [server_socket] - # since a socket is associated with a username and a chatroom, - # and each one can only be associated one way at once, use dictionaries to - # associate with each other. - usernames = {} - chat_rooms = {} - # keep track of the states of each client connected, and if they are - # technically in an 'unsafe' state like in change username or chatroom mode. - client_states = {} + # load persistent data + client_info, chat_rooms, client_states = load_data() + uuid_dict = load_uuids() # let the user know the server is listening on port CHATPORT. print(f"Chatserver listening on port: {CHATPORT}") @@ -50,6 +93,9 @@ def main(): client_socket.send(b"\nUse '%!' to exit, or '%?' for help.\n") client_socket.send(b"\nType in a username: \n") client_states[client_socket] = "WAITING_FOR_USERNAME" + # Get or generate a unique user ID for the client + user_id = get_client_uuid(client_socket, uuid_dict) + client_info[client_socket] = {"user_id": user_id} # otherwise the socket is a client socket. else: # try to receive the message from the client @@ -58,14 +104,30 @@ def main(): if message: state = client_states.get(sock, "NORMAL") if state == "WAITING_FOR_USERNAME": - usernames[sock] = message.strip() - chat_rooms[sock] = "default" - sock.send(b"\nUsername accepted. You are now in the 'default' chatroom.\n") - client_states[sock] = "NORMAL" + username = message.strip() + if username == ADMIN_USERNAME: + sock.send(b"\nEnter admin password: \n") + client_states[sock] = "WAITING_FOR_ADMIN_PASSWORD" + client_info[sock]["username"] = username + else: + client_info[sock]["username"] = username + chat_rooms[sock] = "default" + sock.send(b"\nUsername accepted. You are now in the 'default' chatroom.\n") + client_states[sock] = "NORMAL" + elif state == "WAITING_FOR_ADMIN_PASSWORD": + password = message.strip() + if password == ADMIN_PASSWORD: + sock.send(b"\nAdmin authenticated successfully.\n") + client_info[sock]["is_admin"] = True + chat_rooms[sock] = "default" + client_states[sock] = "NORMAL" + else: + sock.send(b"\nInvalid admin password. Please enter a username: \n") + client_states[sock] = "WAITING_FOR_USERNAME" elif state == "WAITING_FOR_NEW_USERNAME": - usernames[sock] = message.strip() + client_info[sock]["username"] = message.strip() sock.send(b"\nUsername changed successfully.\n") - print(f"\nClient switched username to {usernames[sock]}.") + print(f"\nClient switched username to {client_info[sock]['username']}.") client_states[sock] = "NORMAL" elif state == "WAITING_FOR_NEW_CHATROOM": old_chatroom = chat_rooms[sock] @@ -79,7 +141,7 @@ def main(): if message.strip() == "%!": sock.close() socket_list.remove(sock) - del usernames[sock] + del client_info[sock] del chat_rooms[sock] del client_states[sock] print("\nClient has closed connection.") @@ -92,14 +154,38 @@ def main(): sock.send(b"\t%! : Exit from the Chat Server.\n") sock.send(b"\t%* : Switch to another chatroom.\n") sock.send(b"\t%$ : Change Username.\n") + sock.send(b"\t%@ : Send a direct message to a user.\n") + if client_info[sock].get("is_admin"): + sock.send(b"\t%stop : Stop the server.\n") elif message.strip() == "%*": sock.send(b"\nEnter chatroom name: \n") client_states[sock] = "WAITING_FOR_NEW_CHATROOM" elif message.strip() == "%$": sock.send(b"\nEnter new username: \n") client_states[sock] = "WAITING_FOR_NEW_USERNAME" + elif message.strip().startswith("%@ "): + parts = message.strip().split(' ', 2) + if len(parts) < 3: + sock.send(b"\nInvalid direct message format. Use: %@ \n") + else: + target_username = parts[1] + dm_message = parts[2] + target_sock = None + for s, info in client_info.items(): + if info.get("username") == target_username: + target_sock = s + break + if target_sock: + sender_username = client_info[sock]["username"] + target_sock.send(f"DM from {sender_username}: {dm_message}\n".encode('utf-8')) + sock.send(b"\nDirect message sent.\n") + else: + sock.send(b"\nUser not found.\n") + elif message.strip() == "%stop" and client_info[sock].get("is_admin"): + print("\nAdmin has stopped the server.") + quitFlag = False else: - username = usernames[sock] + username = client_info[sock]["username"] chat_room = chat_rooms[sock] send_message = f"{username} >> {message}".encode('utf-8') for client_sock in socket_list: @@ -108,8 +194,8 @@ def main(): else: sock.close() socket_list.remove(sock) - if sock in usernames: - del usernames[sock] + if sock in client_info: + del client_info[sock] if sock in chat_rooms: old_chatroom = chat_rooms[sock] del chat_rooms[sock] @@ -122,8 +208,8 @@ def main(): print(f"Error: {err}") sock.close() socket_list.remove(sock) - if sock in usernames: - del usernames[sock] + if sock in client_info: + del client_info[sock] if sock in chat_rooms: old_chatroom = chat_rooms[sock] del chat_rooms[sock] @@ -133,6 +219,8 @@ def main(): del client_states[sock] print("\nClient has closed connection.") + # Save data before closing the server + save_data(client_info, chat_rooms, client_states) server_socket.close() if __name__ == "__main__": From f3c913c97c6ff4d37dad5181e804c960acf06e33 Mon Sep 17 00:00:00 2001 From: cdez Date: Wed, 19 Mar 2025 17:52:34 -0700 Subject: [PATCH 02/28] This branch has the chatserverplus stuff in it, copied over the client, removed the unnecessary files included. --- chatserver/README.md | 6 +- chatserver/client.py | 55 +++++++++++++++ echoserver/Makefile | 20 ------ echoserver/README.md | 10 --- echoserver/echoserver.c | 150 ---------------------------------------- echoserver/echoserver.h | 25 ------- timeserver/Makefile | 20 ------ timeserver/README.md | 16 ----- timeserver/demo.pcap | Bin 54532 -> 0 bytes timeserver/timeserver.c | 83 ---------------------- timeserver/timeserver.h | 23 ------ 11 files changed, 56 insertions(+), 352 deletions(-) delete mode 100644 echoserver/Makefile delete mode 100644 echoserver/README.md delete mode 100644 echoserver/echoserver.c delete mode 100644 echoserver/echoserver.h delete mode 100644 timeserver/Makefile delete mode 100644 timeserver/README.md delete mode 100644 timeserver/demo.pcap delete mode 100644 timeserver/timeserver.c delete mode 100644 timeserver/timeserver.h diff --git a/chatserver/README.md b/chatserver/README.md index ac65127..47301bf 100644 --- a/chatserver/README.md +++ b/chatserver/README.md @@ -5,8 +5,4 @@ Use `python server.py` to run the server Use the client to communicate with the chatserver by running `python client.py` ## chatserver information -Included is the server and client written in Python. - -## pcap information -A demonstration of the chatserver has been captured with Wireshark in the -included `demo.pcap` file. +Included is the server and client written in Python. \ No newline at end of file diff --git a/chatserver/client.py b/chatserver/client.py index e69de29..9ad3c98 100644 --- a/chatserver/client.py +++ b/chatserver/client.py @@ -0,0 +1,55 @@ +import socket +import sys +import select + +BUFFER_SIZE = 1024 +DEFAULT_HOST = 'localhost' +DEFAULT_PORT = 8181 + +def main(): + if len(sys.argv) != 3: + SERVER_HOST = input(f"Enter server host (default: {DEFAULT_HOST}): ") or DEFAULT_HOST + SERVER_PORT = input(f"Enter server port (default: {DEFAULT_PORT}): ") or DEFAULT_PORT + SERVER_PORT = int(SERVER_PORT) + else: + SERVER_HOST = sys.argv[1] + SERVER_PORT = int(sys.argv[2]) + + # create a socket object + client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + # connect to the server + client_socket.connect((SERVER_HOST, SERVER_PORT)) + print(f"Connected to chat server at {SERVER_HOST}:{SERVER_PORT}") + + while True: + # use select to wait for either server messages or user input + read_sockets, _, _ = select.select([client_socket, sys.stdin], [], []) + + for sock in read_sockets: + if sock == client_socket: + # receive message from server + server_message = client_socket.recv(BUFFER_SIZE).decode('utf-8') + if not server_message: + print("Disconnected from server") + return + print(f"\n{server_message}") + print("> ", end="", flush=True) + else: + # prompt for user input + client_message = input() + if client_message.strip() == "%!": + print("Exiting chat...") + return + + # send message to server + client_socket.send(client_message.encode('utf-8')) + + except Exception as e: + print(f"Error: {e}") + finally: + client_socket.close() + print("Connection closed") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/echoserver/Makefile b/echoserver/Makefile deleted file mode 100644 index 221b29f..0000000 --- a/echoserver/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -CC = gcc -Wall -std=c11 -pedantic -SOURCES = echoserver.c -OBJECTS = echoserver.o - -all: echoserver - -echoserver: $(OBJECTS) - $(CC) $(OBJECTS) -o echoserver - -echoserver.o: echoserver.c - $(CC) -c echoserver.c -o echoserver.o - -clean: - rm -f $(OBJECTS) echoserver echoserver.out - -clean_objects: - rm -f $(OBJECTS) - -run: - ./echoserver diff --git a/echoserver/README.md b/echoserver/README.md deleted file mode 100644 index fb81333..0000000 --- a/echoserver/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Running the echoserver -Use `make` to compile the echoserver. -Use `make run` to run the echoserver. This will run on an infinite loop. -Use `Ctrl+C` to stop the echoserver. - -## echoserver communication -Communicating with the server can happen using telnet in TCP mode. - -## echoserver information -The echoserver runs on TCP port 8888. \ No newline at end of file diff --git a/echoserver/echoserver.c b/echoserver/echoserver.c deleted file mode 100644 index 73e2bbc..0000000 --- a/echoserver/echoserver.c +++ /dev/null @@ -1,150 +0,0 @@ -// header files -#include "echoserver.h" - -// constants -// echoserver port is 8888 -#define ECHOPORT 8888 -#define BUFFER_SIZE 1024 -#define MAX_CONNECT_QUEUE 10 - -// prototypes - -// main function -int main(int argc, char **argv) -{ - // initialize program (variables and structs) - bool quitFlag = true; - int socketEchoFD; - int clientEchoFD; - int reuseAddrOpt = 1; - int keepAliveOpt = 1; - char message[BUFFER_SIZE]; - int messageBytes; - struct sockaddr_in serverAddr; - struct sockaddr_storage clientAddr; - socklen_t clientSize; - memset(&serverAddr, 0, sizeof(serverAddr)); - memset(&clientAddr, 0, sizeof(clientAddr)); - // define a TCP socket - socketEchoFD = socket(AF_INET, SOCK_STREAM, 0); - if(socketEchoFD < 0) - { - printf("Unable to create the socket.\n"); - return -1; - } - // use the setsockopt function to make the socket accept different configurations - // allow for the reuse of the address/bind. - setsockopt(socketEchoFD, SOL_SOCKET, SO_REUSEADDR, &reuseAddrOpt, sizeof(reuseAddrOpt)); - // also need to set the socket to keepalive using setsockopt - setsockopt(socketEchoFD, SOL_SOCKET, SO_KEEPALIVE, &keepAliveOpt, sizeof(keepAliveOpt)); - // bind the TCP socket to port 8888 / ECHOPORT - serverAddr.sin_family = AF_INET; - serverAddr.sin_addr.s_addr = INADDR_ANY; - serverAddr.sin_port = htons(ECHOPORT); - int bindCheck = bind(socketEchoFD, &serverAddr, sizeof(serverAddr)); - if(bindCheck < 0) - { - printf("Unable to bind the socket to the port or address.\n Is either in use?"); - return -1; - } - // use fnctl to set the non-blocking flag for the socket - fcntl(socketEchoFD, F_SETFL, O_NONBLOCK); - // the server will now accept client connections by listening. - listen(socketEchoFD, MAX_CONNECT_QUEUE); - // create a file descriptor set, to keep track of the sockets in use using fd_set and FD_ZERO? - fd_set socketList; - FD_ZERO(&socketList); - // add the socketEchoFD to the file descriptor set using fd_set - FD_SET(socketEchoFD, &socketList); - // max file descriptor, this will be used to 'find' the socket currently connected - int maxFD = socketEchoFD; - // while loop that will continue to listen for messages from clients. - printf("Echoserver listening on port: %d\n", ECHOPORT); - while (quitFlag) - { - // set of active connections - fd_set activeSet = socketList; - // use the select() function here to check the set of sockets - int selectedFD = select(maxFD+1, &activeSet, NULL, NULL, NULL); - // check to make sure select() didn't error out. - if(selectedFD == -1) - { - // - printf("\nUnable to select socket to listen to.\n"); - return -1; - } - // for loop that loops up to the max file descriptor. - for(int fileIter = 0; fileIter <= maxFD; fileIter++) - { - // check to see if the file descriptor is in the set of sockets using FD_ISSET() - if(FD_ISSET(fileIter, &activeSet)) - { - // if the server socket is being written to/read from, - if(fileIter == socketEchoFD) - { - // create a client socket using accept - clientSize = sizeof(clientAddr); - clientEchoFD = accept(socketEchoFD, &clientAddr, &clientSize); - // check to see if the client socket is connected (-1) - if(clientEchoFD == -1) - { - printf("\nCould not connect the client to a socket.\n"); - return -1; - } - else - { - // set the non blocking flag using fnctl - fcntl(clientEchoFD, F_SETFL, O_NONBLOCK); - // add the client socket to the set of file descriptor sockets - FD_SET(clientEchoFD, &socketList); - // check to see if the new socket FD is greater than the current max (clientSocket > maxFD) - if(clientEchoFD > maxFD) - { - // if it is, set the new max. - maxFD = clientEchoFD; - } - printf("\nConnected client to socket.\n"); - } - printf("\nAccepted connection from new client.\n"); - } - // if the client socket(s) are being written to/read from, - else - { - // grab the data from the client, save to message. - messageBytes = recv(fileIter, message, BUFFER_SIZE, 0); - // make sure that the connection is still open before attempting to send. - if(messageBytes <= 0) // old: messageBytes <= 0 - { - // if it is not open, close the socket and remove the socket from the socket list using FD_CLR() - close(fileIter); - FD_CLR(fileIter, &socketList); - printf("\nClient has closed connection.\n"); - } - else - { - // if it is open, send the message back to the client over the client socket - send(fileIter, message, messageBytes, 0); - printf("\nMessage has been sent back to client.\n"); - } - } - } - } - } - // close the socket - close(socketEchoFD); - // profit??? - return 0; -} - -// function definitions - -/* -Name: ??? -Process: ??? -Function Input/Parameters: ??? -Function Output/Parameters: ??? -Function Output/Returned: ??? -Device Input/Device: ??? -Device Output/Device: ??? -Dependencies: ??? -*/ \ No newline at end of file diff --git a/echoserver/echoserver.h b/echoserver/echoserver.h deleted file mode 100644 index b1ef31c..0000000 --- a/echoserver/echoserver.h +++ /dev/null @@ -1,25 +0,0 @@ -#ifndef ECHOSERVER -#define ECHOSERVER - -// included libraries -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// data structures - -// function prototypes - -#endif // ECHOSERVER \ No newline at end of file diff --git a/timeserver/Makefile b/timeserver/Makefile deleted file mode 100644 index 6f7d8b7..0000000 --- a/timeserver/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -CC = gcc -Wall -std=c11 -pedantic -SOURCES = timeserver.c -OBJECTS = timeserver.o - -all: timeserver - -timeserver: $(OBJECTS) - $(CC) $(OBJECTS) -o timeserver - -timeserver.o: timeserver.c - $(CC) -c timeserver.c -o timeserver.o - -clean: - rm -f $(OBJECTS) timeserver timeserver.out - -clean_objects: - rm -f $(OBJECTS) - -run: - ./timeserver diff --git a/timeserver/README.md b/timeserver/README.md deleted file mode 100644 index b9d8b6a..0000000 --- a/timeserver/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Running the timeserver -Use `make` to compile the timeserver. -Use `make run` to run the timeserver. This will run on an infinite loop. -Use `Ctrl+C` to stop the timeserver. - -## timeserver communication -Communicating with the server can happen using netcat in UDP mode (-u flag). - -## timeserver information -The timeserver runs on UDP port 8888. -The timeserver will wait for `time\r\n` to be sent on this port, and if received, -will send back a response of the time (in ms) since January 1st, 1970 (UNIX Epoch). - -## pcap information -A demonstration of the timeserver has been captured with Wireshark in the -included `demo.pcap` file. \ No newline at end of file diff --git a/timeserver/demo.pcap b/timeserver/demo.pcap deleted file mode 100644 index 6c48bde8ec57a25a305c881fb47c3ae03d1ad8e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54532 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 P3o$~>Vg<610mu#j1EFy= diff --git a/timeserver/timeserver.c b/timeserver/timeserver.c deleted file mode 100644 index 6eef908..0000000 --- a/timeserver/timeserver.c +++ /dev/null @@ -1,83 +0,0 @@ -// header files -#include "timeserver.h" - -// constants -// timeserver port is 8888 -#define TIMEPORT 8888 -#define BUFFER_SIZE 1024 - -// prototypes - -// main function -int main(int argc, char **argv) -{ - // initialize program (variables and structs) - bool quitFlag = true; - int socketTimeFD; - char message[BUFFER_SIZE]; - struct sockaddr_in serverAddr, clientAddr; - memset(&serverAddr, 0, sizeof(serverAddr)); - memset(&clientAddr, 0, sizeof(clientAddr)); - // define a UDP socket - socketTimeFD = socket(AF_INET, SOCK_DGRAM, 0); - if(socketTimeFD < 0) - { - printf("Unable to create the socket."); - return -1; - } - // bind the UDP socket to port 8888 / TIMEPORT - serverAddr.sin_family = AF_INET; - serverAddr.sin_addr.s_addr = INADDR_ANY; - serverAddr.sin_port = htons(TIMEPORT); - int bindCheck = bind(socketTimeFD, &serverAddr, sizeof(serverAddr)); - if(bindCheck < 0) - { - printf("Unable to bind the socket to the port or address.\n Is either in use?"); - return -1; - } - // while loop to see if the "time\r\n" has been received (recv call?) - printf("Timeserver listening on port: %d\n", TIMEPORT); - while(quitFlag) - { - int clientAddrLen; - int receivedDataLen; - // grab the data from the client - receivedDataLen = recvfrom(socketTimeFD, message, BUFFER_SIZE, MSG_WAITALL, &clientAddr, &clientAddrLen); - message[receivedDataLen] = '\0'; - // if "time\r\n" is received, then send() back the linux epoch. - if(strcmp(message, "time\n") == 0 || strcmp(message, "time\r\n") == 0) - { - // setup the reply message - char reply[BUFFER_SIZE]; - // add the current time to the reply - sprintf(reply, "%d", time(NULL)); - // send the reply to the requestee - sendto(socketTimeFD, (const char *)reply, strlen(reply), 0, (const struct sockaddr *) &clientAddr, clientAddrLen); - // log the time command recv'd - printf("\nServer received time. Replied with: %d\n", time(NULL)); - } - else if (strcmp(message, "close\n") == 0 || strcmp(message, "quit\n") == 0 || strcmp(message, "q\n") == 0) - { - // if the server recv'd the close command, then close the server. - printf("\nServer received close.\n"); - quitFlag = false; - } - } - // close the socket - close(socketTimeFD); - // profit??? - return 0; -} - -// function definitions - -/* -Name: ??? -Process: ??? -Function Input/Parameters: ??? -Function Output/Parameters: ??? -Function Output/Returned: ??? -Device Input/Device: ??? -Device Output/Device: ??? -Dependencies: ??? -*/ \ No newline at end of file diff --git a/timeserver/timeserver.h b/timeserver/timeserver.h deleted file mode 100644 index 1a8135b..0000000 --- a/timeserver/timeserver.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifndef TIMESERVER -#define TIMESERVER - -// included libraries -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// data structures - -// function prototypes - -#endif // TIMESERVER \ No newline at end of file From aad3ecd8040e47b1e2b4b471782ca305e2039ed1 Mon Sep 17 00:00:00 2001 From: cdez Date: Wed, 19 Mar 2025 18:38:20 -0700 Subject: [PATCH 03/28] Allow for both Windows and Linux to use the same 'client' --- chatserver/client.py | 60 ++++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/chatserver/client.py b/chatserver/client.py index 9ad3c98..74ccf2b 100644 --- a/chatserver/client.py +++ b/chatserver/client.py @@ -1,6 +1,7 @@ import socket import sys import select +import os BUFFER_SIZE = 1024 DEFAULT_HOST = 'localhost' @@ -22,29 +23,50 @@ def main(): client_socket.connect((SERVER_HOST, SERVER_PORT)) print(f"Connected to chat server at {SERVER_HOST}:{SERVER_PORT}") - while True: - # use select to wait for either server messages or user input - read_sockets, _, _ = select.select([client_socket, sys.stdin], [], []) - - for sock in read_sockets: - if sock == client_socket: - # receive message from server + if os.name == 'nt': # Windows-specific logic + import msvcrt + client_socket.setblocking(False) # Set the socket to non-blocking mode + + while True: + # Check for server messages + try: server_message = client_socket.recv(BUFFER_SIZE).decode('utf-8') - if not server_message: - print("Disconnected from server") - return - print(f"\n{server_message}") - print("> ", end="", flush=True) - else: - # prompt for user input - client_message = input() + if server_message: + print(f"\n{server_message}") + print("> ", end="", flush=True) + except BlockingIOError: + pass + + # Check for user input + if msvcrt.kbhit(): + client_message = input("> ") if client_message.strip() == "%!": print("Exiting chat...") - return - - # send message to server + break client_socket.send(client_message.encode('utf-8')) - + + else: # Linux/Unix-specific logic + while True: + # use select to wait for either server messages or user input + read_sockets, _, _ = select.select([client_socket, sys.stdin], [], []) + + for sock in read_sockets: + if sock == client_socket: + # receive message from server + server_message = client_socket.recv(BUFFER_SIZE).decode('utf-8') + if not server_message: + print("Disconnected from server") + return + print(f"\n{server_message}") + print("> ", end="", flush=True) + else: + # prompt for user input + client_message = input() + if client_message.strip() == "%!": + print("Exiting chat...") + return + client_socket.send(client_message.encode('utf-8')) + except Exception as e: print(f"Error: {e}") finally: From dab35d98328197d303b9b8fee0f01c4330c5f726 Mon Sep 17 00:00:00 2001 From: cdez Date: Thu, 27 Mar 2025 11:56:06 -0700 Subject: [PATCH 04/28] Modify the server to add a REST API that allows for server-side message history, and a selfhosted webclient. The client remains unchanged, but maintains backwards compat with the server. --- chatserver/server.py | 169 +++++++++++++++++++++++++------- chatserver/templates/index.html | 74 ++++++++++++++ chatserver/webclient.py | 10 ++ 3 files changed, 219 insertions(+), 34 deletions(-) create mode 100644 chatserver/templates/index.html create mode 100644 chatserver/webclient.py diff --git a/chatserver/server.py b/chatserver/server.py index dd6bb99..ca5a6d5 100644 --- a/chatserver/server.py +++ b/chatserver/server.py @@ -3,15 +3,31 @@ import select import uuid import pickle import os +import threading +from flask import Flask, request, jsonify +from flask_socketio import SocketIO, emit, join_room, leave_room + +# Flask app for REST API and WebSocket +app = Flask(__name__) +socketio = SocketIO(app) # Initialize Flask-SocketIO CHATPORT = 8181 BUFFER_SIZE = 1024 MAX_CONNECT_QUEUE = 10 DATA_FILE = 'server_data.pkl' -ADMIN_USERNAME = "admin" -ADMIN_PASSWORD = "adminpass" UUID_FILE = 'uuids.pkl' +# Global variables for chat server +socket_list = [] +client_info = {} +chat_rooms = {} +client_states = {} +uuid_dict = {} +message_history = {} # New: Store message history for each chat room +server_socket = None +quitFlag = True + +# Utility functions def save_data(client_info, chat_rooms, client_states): serializable_client_info = {info["user_id"]: info for sock, info in client_info.items()} serializable_chat_rooms = {info["user_id"]: chat_rooms[sock] for sock, info in client_info.items()} @@ -52,60 +68,133 @@ def get_client_uuid(client_socket, uuid_dict): return user_id -def main(): - global quitFlag - # set the flag variable to true for infinite loop - quitFlag = True - # create a socket object +# REST API Endpoints +@app.route('/send_message', methods=['POST']) +def send_message(): + """Send a message to a specific chat room.""" + data = request.json + username = data.get('username') + chat_room = data.get('chat_room', 'default') + message = data.get('message') + + if not username or not message: + return jsonify({"error": "Username and message are required"}), 400 + + # Store the message in the chat room's history + if chat_room not in message_history: + message_history[chat_room] = [] + message_history[chat_room].append(f"{username}: {message}") + + # Broadcast the message to all clients in the same chat room + send_message = f"{username} >> {message}".encode('utf-8') + for client_sock in socket_list: + if client_sock != server_socket and chat_rooms.get(client_sock) == chat_room: + client_sock.send(send_message) + + return jsonify({"status": "Message sent and saved in history"}), 200 + +@app.route('/create_chatroom', methods=['POST']) +def create_chatroom(): + """Create a new chat room.""" + data = request.json + chat_room = data.get('chat_room') + + if not chat_room: + return jsonify({"error": "Chat room name is required"}), 400 + + # Add logic to create a chat room (if needed) + return jsonify({"status": f"Chat room '{chat_room}' created"}), 200 + +@app.route('/get_messages', methods=['GET']) +def get_messages(): + """Retrieve messages for a specific user (not implemented here).""" + # You can implement a message queue or buffer for each user to store messages + return jsonify({"error": "Not implemented"}), 501 + +@app.route('/get_history', methods=['GET']) +def get_history(): + """Retrieve message history for a specific chat room.""" + chat_room = request.args.get('chat_room', 'default') + if chat_room not in message_history: + return jsonify({"error": f"No history found for chat room '{chat_room}'"}), 404 + + return jsonify({"chat_room": chat_room, "messages": message_history[chat_room]}), 200 + +# WebSocket event handlers +@socketio.on('connect') +def handle_connect(): + print("WebSocket client connected") + +@socketio.on('disconnect') +def handle_disconnect(): + print("WebSocket client disconnected") + +@socketio.on('join') +def handle_join(data): + username = data.get('username') + chat_room = data.get('chat_room', 'default') + join_room(chat_room) + emit('message', {'message': f"{username} has joined the chat room '{chat_room}'."}, room=chat_room) + +@socketio.on('leave') +def handle_leave(data): + username = data.get('username') + chat_room = data.get('chat_room', 'default') + leave_room(chat_room) + emit('message', {'message': f"{username} has left the chat room '{chat_room}'."}, room=chat_room) + +@socketio.on('send_message') +def handle_send_message(data): + username = data.get('username') + chat_room = data.get('chat_room', 'default') + message = data.get('message') + + # Store the message in the chat room's history + if chat_room not in message_history: + message_history[chat_room] = [] + message_history[chat_room].append(f"{username}: {message}") + + # Broadcast the message to the chat room + emit('message', {'username': username, 'message': message}, room=chat_room) + +# Chat server logic +def chat_server(): + global quitFlag, server_socket, socket_list, client_info, chat_rooms, client_states, uuid_dict + + # Create and configure the server socket server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - # set the socket options, SO_REUSEADDR allows to reuse the same port - # SO_KEEPALIVE enables the socket to send keepalive messages server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) - # bind the socket to the address and port server_socket.bind(('', CHATPORT)) - # listen for incoming connections server_socket.listen(MAX_CONNECT_QUEUE) - # set the server socket to non-blocking server_socket.setblocking(False) - # create a list to store the sockets, server and client - socket_list = [server_socket] - # load persistent data - client_info, chat_rooms, client_states = load_data() - uuid_dict = load_uuids() - # let the user know the server is listening on port CHATPORT. - print(f"Chatserver listening on port: {CHATPORT}") + socket_list.append(server_socket) + print(f"Chat server listening on port {CHATPORT}") - # infinite loop for the program while quitFlag: - # use the select function to monitor the sockets read_sockets, _, _ = select.select(socket_list, [], []) - # loop through the sockets for sock in read_sockets: - # if the socket is the server socket, accept the connection if sock == server_socket: + # Accept new client connections client_socket, client_address = server_socket.accept() client_socket.setblocking(False) socket_list.append(client_socket) - print("\nAccepted connection from new client.") - client_socket.send(b"\nWelcome to the chat server!\n") - client_socket.send(b"\nUse '%!' to exit, or '%?' for help.\n") - client_socket.send(b"\nType in a username: \n") + print(f"Accepted connection from {client_address}") + client_socket.send(b"Welcome to the chat server!\n") + client_socket.send(b"Type in a username: \n") client_states[client_socket] = "WAITING_FOR_USERNAME" - # Get or generate a unique user ID for the client user_id = get_client_uuid(client_socket, uuid_dict) client_info[client_socket] = {"user_id": user_id} - # otherwise the socket is a client socket. else: - # try to receive the message from the client + # Handle client messages try: message = sock.recv(BUFFER_SIZE).decode('utf-8') if message: state = client_states.get(sock, "NORMAL") if state == "WAITING_FOR_USERNAME": username = message.strip() - if username == ADMIN_USERNAME: + if username == "admin": sock.send(b"\nEnter admin password: \n") client_states[sock] = "WAITING_FOR_ADMIN_PASSWORD" client_info[sock]["username"] = username @@ -116,7 +205,7 @@ def main(): client_states[sock] = "NORMAL" elif state == "WAITING_FOR_ADMIN_PASSWORD": password = message.strip() - if password == ADMIN_PASSWORD: + if password == "adminpass": sock.send(b"\nAdmin authenticated successfully.\n") client_info[sock]["is_admin"] = True chat_rooms[sock] = "default" @@ -188,6 +277,13 @@ def main(): username = client_info[sock]["username"] chat_room = chat_rooms[sock] send_message = f"{username} >> {message}".encode('utf-8') + + # Store the message in the chat room's history + if chat_room not in message_history: + message_history[chat_room] = [] + message_history[chat_room].append(f"{username}: {message}") + + # Broadcast the message to all clients in the same chat room for client_sock in socket_list: if client_sock != server_socket and chat_rooms.get(client_sock) == chat_room: client_sock.send(send_message) @@ -219,9 +315,14 @@ def main(): del client_states[sock] print("\nClient has closed connection.") - # Save data before closing the server + # Save data before shutting down save_data(client_info, chat_rooms, client_states) server_socket.close() if __name__ == "__main__": - main() \ No newline at end of file + # Start the chat server in a separate thread + server_thread = threading.Thread(target=chat_server, daemon=True) + server_thread.start() + + # Start the Flask REST API and WebSocket server + socketio.run(app, host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html new file mode 100644 index 0000000..6bb20fc --- /dev/null +++ b/chatserver/templates/index.html @@ -0,0 +1,74 @@ + + + + + + Web Chat Client + + + + +
+
+ + +
+ + + + \ No newline at end of file diff --git a/chatserver/webclient.py b/chatserver/webclient.py new file mode 100644 index 0000000..2a1856c --- /dev/null +++ b/chatserver/webclient.py @@ -0,0 +1,10 @@ +from flask import Flask, render_template + +app = Flask(__name__) + +@app.route('/') +def index(): + return render_template('index.html') # Serve the HTML client + +if __name__ == "__main__": + app.run(host='0.0.0.0', port=8080) # Run the web client on port 8080 \ No newline at end of file From 44cdbde0032a7145a6b498a424260418d6dca4b1 Mon Sep 17 00:00:00 2001 From: cdez Date: Thu, 27 Mar 2025 12:22:18 -0700 Subject: [PATCH 05/28] Modify the selfhosted webclient. Tried to fix the errors? The server has been modified too to try and compensate for errors. --- chatserver/server.py | 13 ++++++++++++- chatserver/templates/index.html | 9 +++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/chatserver/server.py b/chatserver/server.py index ca5a6d5..cbc42e4 100644 --- a/chatserver/server.py +++ b/chatserver/server.py @@ -131,10 +131,16 @@ def handle_disconnect(): @socketio.on('join') def handle_join(data): + print(f"Received join event: {data}") username = data.get('username') chat_room = data.get('chat_room', 'default') + + if not username: + emit('error', {'message': 'Username is required'}) + return + join_room(chat_room) - emit('message', {'message': f"{username} has joined the chat room '{chat_room}'."}, room=chat_room) + emit('message', {'username': 'Server', 'message': f"{username} has joined the chat room '{chat_room}'."}, room=chat_room) @socketio.on('leave') def handle_leave(data): @@ -145,10 +151,15 @@ def handle_leave(data): @socketio.on('send_message') def handle_send_message(data): + print(f"Received send_message event: {data}") username = data.get('username') chat_room = data.get('chat_room', 'default') message = data.get('message') + if not username or not message: + emit('error', {'message': 'Username and message are required'}) + return + # Store the message in the chat room's history if chat_room not in message_history: message_history[chat_room] = [] diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index 6bb20fc..64f0841 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -50,6 +50,8 @@ let username = prompt("Enter your username:"); let chatRoom = prompt("Enter chat room (default: 'default')") || 'default'; + // Log the join event data + console.log('Joining chat room:', { username: username, chat_room: chatRoom }); // Join the chat room socket.emit('join', { username: username, chat_room: chatRoom }); @@ -65,10 +67,17 @@ function sendMessage() { const message = messageInput.value; if (message.trim() !== '') { + // Log the send_message event data + console.log('Sending message:', { username: username, chat_room: chatRoom, message: message }); socket.emit('send_message', { username: username, chat_room: chatRoom, message: message }); messageInput.value = ''; // Clear the input field } } + + // Log the send_message event data + console.log('Sending message:', { username: username, chat_room: chatRoom, message: message }); + // Send a message to the server + socket.emit('send_message', { username: username, chat_room: chatRoom, message: message }); \ No newline at end of file From 0fd8ce778830362209fab0bd4bf023341e4a2f07 Mon Sep 17 00:00:00 2001 From: cdez Date: Thu, 27 Mar 2025 18:30:18 -0700 Subject: [PATCH 06/28] Attempted fix for the selfhosted webclient. --- chatserver/templates/index.html | 41 +++++---------------------------- 1 file changed, 6 insertions(+), 35 deletions(-) diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index 64f0841..93f9a5e 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -6,33 +6,11 @@ Web Chat Client @@ -50,9 +28,8 @@ let username = prompt("Enter your username:"); let chatRoom = prompt("Enter chat room (default: 'default')") || 'default'; - // Log the join event data - console.log('Joining chat room:', { username: username, chat_room: chatRoom }); // Join the chat room + console.log('Joining chat room:', { username: username, chat_room: chatRoom }); socket.emit('join', { username: username, chat_room: chatRoom }); // Listen for messages from the server @@ -67,17 +44,11 @@ function sendMessage() { const message = messageInput.value; if (message.trim() !== '') { - // Log the send_message event data console.log('Sending message:', { username: username, chat_room: chatRoom, message: message }); socket.emit('send_message', { username: username, chat_room: chatRoom, message: message }); messageInput.value = ''; // Clear the input field } } - - // Log the send_message event data - console.log('Sending message:', { username: username, chat_room: chatRoom, message: message }); - // Send a message to the server - socket.emit('send_message', { username: username, chat_room: chatRoom, message: message }); \ No newline at end of file From 1e2a7572a67ef5c8fa12d2ad718bd4b3cabcbdaf Mon Sep 17 00:00:00 2001 From: cdez Date: Thu, 27 Mar 2025 18:35:47 -0700 Subject: [PATCH 07/28] Attempt two of fixing the webclient. --- chatserver/templates/index.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index 93f9a5e..5bc5386 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -32,6 +32,14 @@ console.log('Joining chat room:', { username: username, chat_room: chatRoom }); socket.emit('join', { username: username, chat_room: chatRoom }); + socket.on('connect', () => { + console.log('Connected to WebSocket server'); + }); + + socket.on('disconnect', () => { + console.log('Disconnected from WebSocket server'); + }); + // Listen for messages from the server socket.on('message', (data) => { const messageElement = document.createElement('div'); From bd32940581631a8cc6a48dd68c3fa9d47563a879 Mon Sep 17 00:00:00 2001 From: cdez Date: Thu, 27 Mar 2025 18:39:41 -0700 Subject: [PATCH 08/28] Fix the webclient attempt four. --- chatserver/templates/index.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index 5bc5386..80a1c14 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -21,10 +21,14 @@ - + + +
+ Username: | Chat Room: +
+ + + +
+
+ +
+ +
- +
\ No newline at end of file From 38e5049447c47a74f27fa9a27ca7621afc0859c3 Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 17:46:25 -0700 Subject: [PATCH 13/28] Allow the webclient to have a default for the WebSocket server to default to where it's hosted. --- chatserver/templates/index.html | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index 86ae479..46a4d96 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -106,7 +106,7 @@ } - +
Username: | Chat Room: @@ -127,9 +127,14 @@
\ No newline at end of file From 20287030ac6d3a2488b8833ed1a50914757a9921 Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 18:18:51 -0700 Subject: [PATCH 14/28] Improve the selfhosted webclient with a few new buttons! --- chatserver/templates/index.html | 42 +++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index 46a4d96..0c23526 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -109,11 +109,13 @@
- Username: | Chat Room: + Username: | Chat Room: | Server:
+ +
@@ -131,7 +133,7 @@ const defaultServerUrl = `${window.location.protocol}//${window.location.hostname}:5000`; // Ask the user for the server URL, defaulting to the web client's host - const serverUrl = prompt(`Enter the WebSocket server URL (default: ${defaultServerUrl}):`) || defaultServerUrl; + let serverUrl = prompt(`Enter the WebSocket server URL (default: ${defaultServerUrl}):`) || defaultServerUrl; // Connect to the WebSocket server const socket = io(serverUrl); @@ -140,6 +142,10 @@ const messageInput = document.getElementById('message'); const currentUsernameElement = document.getElementById('currentUsername'); const currentChatRoomElement = document.getElementById('currentChatRoom'); + const currentServerElement = document.getElementById('currentServer'); + + // Set the initial server URL in the top bar + currentServerElement.textContent = serverUrl; // Ask the user for their username and chat room let username = prompt("Enter your username:") || "Anonymous"; @@ -211,6 +217,38 @@ } } + // Disconnect from the current server + function disconnectServer() { + if (socket.connected) { + socket.disconnect(); + console.log("Disconnected from the server"); + alert("You have been disconnected from the server."); + currentServerElement.textContent = "Disconnected"; // Update the displayed server URL + } else { + alert("You are not connected to any server."); + } + } + + // Change the server to connect to + function changeServer() { + if (socket.connected) { + socket.disconnect(); + console.log("Disconnected from the current server"); + } + + const newServerUrl = prompt("Enter the new WebSocket server URL (e.g., http://localhost:5000):"); + if (newServerUrl && newServerUrl.trim() !== "") { + serverUrl = newServerUrl.trim(); + console.log(`Connecting to new server: ${serverUrl}`); + socket.io.uri = serverUrl; // Update the server URL + socket.connect(); // Reconnect to the new server + currentServerElement.textContent = serverUrl; // Update the displayed server URL + alert(`Connected to the new server: ${serverUrl}`); + } else { + alert("Server URL cannot be empty."); + } + } + // Toggle between light mode and dark mode function toggleTheme() { const body = document.body; From 8a1728f0833c9263daa8ab63cb0649268a6799e5 Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 18:29:23 -0700 Subject: [PATCH 15/28] Add new features to the webclient, and allow for message history to be displayed in the webclient. --- chatserver/templates/index.html | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index 0c23526..81fd396 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -159,6 +159,25 @@ console.log('Joining chat room:', { username: username, chat_room: chatRoom }); socket.emit('join', { username: username, chat_room: chatRoom }); + // Fetch message history for the initial chat room + fetch(`/get_history?chat_room=${chatRoom}`) + .then(response => response.json()) + .then(data => { + if (data.messages) { + data.messages.forEach(message => { + const messageElement = document.createElement('div'); + messageElement.textContent = message; + chatDiv.appendChild(messageElement); + }); + chatDiv.scrollTop = chatDiv.scrollHeight; // Auto-scroll to the bottom + } else { + console.log("No message history found for this chat room."); + } + }) + .catch(error => { + console.error("Error fetching message history:", error); + }); + // Log connection status socket.on('connect', () => { console.log('Connected to WebSocket server'); @@ -214,6 +233,25 @@ console.log(`Switched to chat room: ${chatRoom}`); socket.emit('join', { username: username, chat_room: chatRoom }); chatDiv.innerHTML = ""; // Clear the chat history + + // Fetch message history for the new chat room + fetch(`/get_history?chat_room=${chatRoom}`) + .then(response => response.json()) + .then(data => { + if (data.messages) { + data.messages.forEach(message => { + const messageElement = document.createElement('div'); + messageElement.textContent = message; + chatDiv.appendChild(messageElement); + }); + chatDiv.scrollTop = chatDiv.scrollHeight; // Auto-scroll to the bottom + } else { + console.log("No message history found for this chat room."); + } + }) + .catch(error => { + console.error("Error fetching message history:", error); + }); } } @@ -249,6 +287,17 @@ } } + // Reconnect to the server + function reconnectServer() { + if (!socket.connected) { + console.log("Reconnecting to the server..."); + socket.connect(); + alert("Reconnected to the server."); + } else { + alert("You are already connected to the server."); + } + } + // Toggle between light mode and dark mode function toggleTheme() { const body = document.body; From 2c02fde8c5f4fceeda9f32d7044ac12dcc2a61bb Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 18:42:57 -0700 Subject: [PATCH 16/28] Attempt to fix the message history for the webclient and REST API. --- chatserver/server.py | 4 ++-- chatserver/templates/index.html | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/chatserver/server.py b/chatserver/server.py index 94d79ad..8da9166 100644 --- a/chatserver/server.py +++ b/chatserver/server.py @@ -209,9 +209,9 @@ def get_history(): """Retrieve message history for a specific chat room.""" chat_room = request.args.get('chat_room', 'default') if chat_room not in message_history: - return jsonify({"error": f"No history found for chat room '{chat_room}'"}), 404 + return jsonify({"messages": []}) # Return an empty list if no history exists - return jsonify({"chat_room": chat_room, "messages": message_history[chat_room]}), 200 + return jsonify({"messages": message_history[chat_room]}) # WebSocket event handlers @socketio.on('connect') diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index 81fd396..8c59b08 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -235,10 +235,15 @@ chatDiv.innerHTML = ""; // Clear the chat history // Fetch message history for the new chat room - fetch(`/get_history?chat_room=${chatRoom}`) - .then(response => response.json()) + fetch(`/get_history?chat_room=${encodeURIComponent(chatRoom)}`) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) .then(data => { - if (data.messages) { + if (data.messages && data.messages.length > 0) { data.messages.forEach(message => { const messageElement = document.createElement('div'); messageElement.textContent = message; From 9e8158a5c0a15ae0492134ba607f7a78bf8332e5 Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 18:51:47 -0700 Subject: [PATCH 17/28] Attempt three? at making the message history pop back up for a room. --- chatserver/templates/index.html | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index 8c59b08..b95f068 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -227,12 +227,19 @@ function changeChatRoom() { const newChatRoom = prompt("Enter the new chat room:"); if (newChatRoom && newChatRoom.trim() !== "") { + // Leave the current chat room socket.emit('leave', { username: username, chat_room: chatRoom }); + + // Update the chat room chatRoom = newChatRoom.trim(); currentChatRoomElement.textContent = chatRoom; console.log(`Switched to chat room: ${chatRoom}`); + + // Join the new chat room socket.emit('join', { username: username, chat_room: chatRoom }); - chatDiv.innerHTML = ""; // Clear the chat history + + // Clear the chat history in the UI + chatDiv.innerHTML = ""; // Fetch message history for the new chat room fetch(`/get_history?chat_room=${encodeURIComponent(chatRoom)}`) @@ -244,6 +251,7 @@ }) .then(data => { if (data.messages && data.messages.length > 0) { + // Display each message in the chat history data.messages.forEach(message => { const messageElement = document.createElement('div'); messageElement.textContent = message; From 72c6735cba7c398f70bf32a02fefa0c32dd542bc Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 19:07:36 -0700 Subject: [PATCH 18/28] Attempt four of making the webclient work with message history. --- chatserver/templates/index.html | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index b95f068..b2dcb17 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -160,10 +160,17 @@ socket.emit('join', { username: username, chat_room: chatRoom }); // Fetch message history for the initial chat room - fetch(`/get_history?chat_room=${chatRoom}`) - .then(response => response.json()) + const serverBaseUrl = `${window.location.protocol}//${window.location.hostname}:5000`; + fetch(`${serverBaseUrl}/get_history?chat_room=${encodeURIComponent(chatRoom)}`) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) .then(data => { - if (data.messages) { + if (data.messages && data.messages.length > 0) { + // Display each message in the chat history data.messages.forEach(message => { const messageElement = document.createElement('div'); messageElement.textContent = message; From ab41114b160d75c48e795d75b1ae130976dd6cd6 Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 19:28:44 -0700 Subject: [PATCH 19/28] Fix the CORS issue that arose again? --- chatserver/server.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/chatserver/server.py b/chatserver/server.py index 8da9166..abf74aa 100644 --- a/chatserver/server.py +++ b/chatserver/server.py @@ -6,11 +6,15 @@ import os import threading from flask import Flask, request, jsonify from flask_socketio import SocketIO, emit, join_room, leave_room +from flask_cors import CORS # Flask app for REST API and WebSocket app = Flask(__name__) socketio = SocketIO(app, cors_allowed_origins="*") # Allow all origins +# Enable CORS for the Flask app +CORS(app) + CHATPORT = 8181 BUFFER_SIZE = 1024 MAX_CONNECT_QUEUE = 10 From 21820453bd757544e131f329e7023c1c4e5616f8 Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 19:33:29 -0700 Subject: [PATCH 20/28] Fix the webclient pulling the history from the wrong port... --- chatserver/templates/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index b2dcb17..0e25206 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -249,7 +249,7 @@ chatDiv.innerHTML = ""; // Fetch message history for the new chat room - fetch(`/get_history?chat_room=${encodeURIComponent(chatRoom)}`) + fetch(`${serverBaseUrl}/get_history?chat_room=${encodeURIComponent(chatRoom)}`) .then(response => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); From 66202e154b07b1602158f52dfdbcf417e5f4d1e2 Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 19:59:13 -0700 Subject: [PATCH 21/28] Modify the server and webclient to allow for timestamps and the room that the message came from. --- chatserver/server.py | 28 ++++++++++++++++++++++------ chatserver/templates/index.html | 4 +++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/chatserver/server.py b/chatserver/server.py index abf74aa..cda46f7 100644 --- a/chatserver/server.py +++ b/chatserver/server.py @@ -7,6 +7,7 @@ import threading from flask import Flask, request, jsonify from flask_socketio import SocketIO, emit, join_room, leave_room from flask_cors import CORS +from datetime import datetime # Import for timestamps # Flask app for REST API and WebSocket app = Flask(__name__) @@ -74,16 +75,24 @@ def get_client_uuid(client_socket, uuid_dict): def handle_message(username, chat_room, message): """Store the message in history and broadcast it to all clients.""" + # Add a timestamp to the message + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + # Store the message in the chat room's history if chat_room not in message_history: message_history[chat_room] = [] - message_history[chat_room].append(f"{username}: {message}") + message_history[chat_room].append(f"[{timestamp}] {username}: {message}") # Broadcast the message to WebSocket clients in the chat room - socketio.emit('message', {'username': username, 'message': message}, room=chat_room) + socketio.emit('message', { + 'username': username, + 'message': message, + 'timestamp': timestamp, + 'chat_room': chat_room + }, room=chat_room) # Broadcast the message to socket-based clients in the chat room - send_message = f"{username} >> {message}".encode('utf-8') + send_message = f"[{timestamp}] {username} >> {message}".encode('utf-8') for client_sock in socket_list: if client_sock != server_socket and chat_rooms.get(client_sock) == chat_room: client_sock.send(send_message) @@ -134,6 +143,7 @@ def handle_command(username, chat_room, command, sock=None): else: target_username = parts[1] dm_message = parts[2] + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') target_sock = None for s, info in client_info.items(): if info.get("username") == target_username: @@ -141,11 +151,16 @@ def handle_command(username, chat_room, command, sock=None): break if target_sock: sender_username = username - target_sock.send(f"DM from {sender_username}: {dm_message}\n".encode('utf-8')) + target_sock.send(f"[{timestamp}] DM from {sender_username}: {dm_message}\n".encode('utf-8')) if sock: - sock.send(b"\nDirect message sent.\n") + sock.send(f"[{timestamp}] Direct message sent.\n".encode('utf-8')) else: - socketio.emit('message', {'username': 'Server', 'message': "Direct message sent."}, room=chat_room) + socketio.emit('message', { + 'username': sender_username, + 'message': dm_message, + 'timestamp': timestamp, + 'chat_room': 'Direct Message' + }, room=chat_room) else: error_message = "User not found." if sock: @@ -215,6 +230,7 @@ def get_history(): if chat_room not in message_history: return jsonify({"messages": []}) # Return an empty list if no history exists + # Return the full message history with timestamps return jsonify({"messages": message_history[chat_room]}) # WebSocket event handlers diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index 0e25206..802036d 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -197,7 +197,9 @@ // Listen for messages from the server socket.on('message', (data) => { const messageElement = document.createElement('div'); - messageElement.textContent = `${data.username || 'Server'}: ${data.message}`; + const timestamp = data.timestamp ? `[${data.timestamp}]` : ''; + const chatRoomInfo = data.chat_room ? `(${data.chat_room})` : ''; + messageElement.textContent = `${timestamp} ${data.username || 'Server'} ${chatRoomInfo}: ${data.message}`; chatDiv.appendChild(messageElement); chatDiv.scrollTop = chatDiv.scrollHeight; // Auto-scroll to the bottom }); From e12f8497f452b5f711788411c26ee0325ad44354 Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 20:09:00 -0700 Subject: [PATCH 22/28] Attempt to fix the Direct Messaging capabilities for the clients. --- chatserver/server.py | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/chatserver/server.py b/chatserver/server.py index cda46f7..68f5483 100644 --- a/chatserver/server.py +++ b/chatserver/server.py @@ -144,24 +144,39 @@ def handle_command(username, chat_room, command, sock=None): target_username = parts[1] dm_message = parts[2] timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + # Check if the target is a socket-based client target_sock = None for s, info in client_info.items(): - if info.get("username") == target_username: + if not info.get("is_websocket") and info.get("username") == target_username: target_sock = s break + + # Check if the target is a WebSocket client + target_websocket_sid = None + for sid, info in client_info.items(): + if info.get("is_websocket") and info.get("username") == target_username: + target_websocket_sid = sid + break + if target_sock: + # Send the direct message to the socket-based client sender_username = username target_sock.send(f"[{timestamp}] DM from {sender_username}: {dm_message}\n".encode('utf-8')) if sock: sock.send(f"[{timestamp}] Direct message sent.\n".encode('utf-8')) - else: - socketio.emit('message', { - 'username': sender_username, - 'message': dm_message, - 'timestamp': timestamp, - 'chat_room': 'Direct Message' - }, room=chat_room) + elif target_websocket_sid: + # Send the direct message to the WebSocket client + socketio.emit('message', { + 'username': username, + 'message': dm_message, + 'timestamp': timestamp, + 'chat_room': 'Direct Message' + }, to=target_websocket_sid) + if sock: + sock.send(f"[{timestamp}] Direct message sent.\n".encode('utf-8')) else: + # Target user not found error_message = "User not found." if sock: sock.send(f"\n{error_message}\n".encode('utf-8')) @@ -237,6 +252,10 @@ def get_history(): @socketio.on('connect') def handle_connect(): print("WebSocket client connected") + # Assign a unique ID to the WebSocket client + user_id = str(uuid.uuid4()) + client_info[request.sid] = {"user_id": user_id, "username": None, "is_websocket": True} + print(f"WebSocket client registered with ID: {user_id}") @socketio.on('disconnect') def handle_disconnect(): @@ -252,6 +271,10 @@ def handle_join(data): emit('error', {'message': 'Username is required'}) return + # Update client info + client_info[request.sid]["username"] = username + client_info[request.sid]["chat_room"] = chat_room + join_room(chat_room) emit('message', {'username': 'Server', 'message': f"{username} has joined the chat room '{chat_room}'."}, room=chat_room) From 20b2702fcff91b2f296cb6db08aa94b96e3c497e Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 20:42:38 -0700 Subject: [PATCH 23/28] Add the send direct message route for the REST API. --- chatserver/server.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/chatserver/server.py b/chatserver/server.py index 68f5483..e9b68fe 100644 --- a/chatserver/server.py +++ b/chatserver/server.py @@ -248,6 +248,50 @@ def get_history(): # Return the full message history with timestamps return jsonify({"messages": message_history[chat_room]}) +@app.route('/send_direct_message', methods=['POST']) +def send_direct_message(): + """Send a direct message to a specific user.""" + data = request.json + sender_username = data.get('sender_username') + target_username = data.get('target_username') + message = data.get('message') + + if not sender_username or not target_username or not message: + return jsonify({"error": "Sender username, target username, and message are required"}), 400 + + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + # Check if the target is a socket-based client + target_sock = None + for s, info in client_info.items(): + if not info.get("is_websocket") and info.get("username") == target_username: + target_sock = s + break + + # Check if the target is a WebSocket client + target_websocket_sid = None + for sid, info in client_info.items(): + if info.get("is_websocket") and info.get("username") == target_username: + target_websocket_sid = sid + break + + if target_sock: + # Send the direct message to the socket-based client + target_sock.send(f"[{timestamp}] DM from {sender_username}: {message}\n".encode('utf-8')) + return jsonify({"status": "Direct message sent to socket-based client"}), 200 + elif target_websocket_sid: + # Send the direct message to the WebSocket client + socketio.emit('message', { + 'username': sender_username, + 'message': message, + 'timestamp': timestamp, + 'chat_room': 'Direct Message' + }, to=target_websocket_sid) + return jsonify({"status": "Direct message sent to WebSocket client"}), 200 + else: + # Target user not found + return jsonify({"error": "Target user not found"}), 404 + # WebSocket event handlers @socketio.on('connect') def handle_connect(): From 88e58bb79c383ad80e26c2abb69d479206c93ae4 Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 20:55:00 -0700 Subject: [PATCH 24/28] Fix the Direct Message functionality for all the client options? --- chatserver/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chatserver/server.py b/chatserver/server.py index e9b68fe..cc7edb4 100644 --- a/chatserver/server.py +++ b/chatserver/server.py @@ -315,7 +315,7 @@ def handle_join(data): emit('error', {'message': 'Username is required'}) return - # Update client info + # Update client info for WebSocket clients client_info[request.sid]["username"] = username client_info[request.sid]["chat_room"] = chat_room From 4b7fe624bbdf56520445c619264bb0ea28ce242f Mon Sep 17 00:00:00 2001 From: cdez Date: Fri, 28 Mar 2025 21:08:56 -0700 Subject: [PATCH 25/28] Add markdown support to the webclient --- chatserver/server.py | 6 +++++- chatserver/templates/index.html | 12 +++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/chatserver/server.py b/chatserver/server.py index cc7edb4..40978b3 100644 --- a/chatserver/server.py +++ b/chatserver/server.py @@ -8,6 +8,7 @@ from flask import Flask, request, jsonify from flask_socketio import SocketIO, emit, join_room, leave_room from flask_cors import CORS from datetime import datetime # Import for timestamps +import markdown # Import the markdown library # Flask app for REST API and WebSocket app = Flask(__name__) @@ -78,6 +79,9 @@ def handle_message(username, chat_room, message): # Add a timestamp to the message timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + # Convert the message to HTML using Markdown + html_message = markdown.markdown(message) + # Store the message in the chat room's history if chat_room not in message_history: message_history[chat_room] = [] @@ -86,7 +90,7 @@ def handle_message(username, chat_room, message): # Broadcast the message to WebSocket clients in the chat room socketio.emit('message', { 'username': username, - 'message': message, + 'message': html_message, # Send the HTML-rendered message 'timestamp': timestamp, 'chat_room': chat_room }, room=chat_room) diff --git a/chatserver/templates/index.html b/chatserver/templates/index.html index 802036d..021517b 100644 --- a/chatserver/templates/index.html +++ b/chatserver/templates/index.html @@ -5,6 +5,8 @@ Web Chat Client + +