Compare commits

...

13 commits

Author SHA1 Message Date
a38c09df73
bump version number 0.3.0 2024-04-01 23:50:53 +03:00
3fb94984ec
Client-side changes for cryptographic login 2024-04-01 23:45:43 +03:00
3ceee1db4f
misc changes and added method stub for encryption support 2024-04-01 21:50:05 +03:00
eb76e7203b
Preparation for integration with auth/oauth2 2024-02-19 22:52:59 +02:00
e205d6e149
Added necessary changes to client-side code for HTTPS transport 2024-02-18 16:36:52 +02:00
3957f49e49
remove unnecessary comments -- floor commit for crypto test 2024-02-18 15:36:47 +02:00
513e8d6841
Bump version number to 0.2.1 2024-01-31 21:10:17 +02:00
4117c11c35
CSS styling overhaul
FIXED Issue with rendering time - minutes now in fixed 2-digit format
ADD Sidebar Topbar components to help with UI/UX
ADD Avatar and Menu PNG files for style
TODO Improve styling
ET AL removed redundant code
2024-01-31 21:07:45 +02:00
7e5f5bdf77
Removed copyright statement temporarily 2024-01-31 21:05:57 +02:00
47b0925db9
Update TODO with things to do 2024-01-31 21:05:44 +02:00
4df2355001
sync misc fmt changes 2024-01-24 22:49:50 +02:00
56feab2ea1
format and use more aggressive tabstop widths 2024-01-20 12:06:34 +02:00
7d4953fea6
Refactor and cleanup code 2024-01-20 11:26:57 +02:00
33 changed files with 18445 additions and 18133 deletions

10
TODO.md Normal file
View file

@ -0,0 +1,10 @@
# TODOs for the project
## UI/UX
1. Beautify interface
2. Implement avatar support
## Cybersecurity
1. Implement SSH-based encryption/decryption in transmission
Client 1 => encrypt with pubcert_s => Server => decrypt with privcert_s => encrypt with pubcert_2 => Client 2 => decrypt with privcert2
plaintext => ciphertext => ciphertext => plaintext => ciphertext => ciphertext => plaintext
Clients need to have server's pubcert,
Server needs to contain pubcerts of each client,

35443
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "epq-web-project", "name": "epq-web-project",
"version": "0.2.0", "version": "0.3.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@stomp/stompjs": "^7.0.0", "@stomp/stompjs": "^7.0.0",
@ -11,6 +11,7 @@
"@types/node": "^16.18.68", "@types/node": "^16.18.68",
"@types/react": "^18.2.45", "@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"ed25519": "^0.0.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
@ -41,5 +42,8 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"@types/ed25519": "^0.0.3"
} }
} }

View file

@ -29,9 +29,6 @@
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<div id="copyright">
<pre>Copyright 2024-2025 Zhongheng Liu @ Byron College</pre>
</div>
<!-- <!--
This HTML file is a template. This HTML file is a template.
If you open it directly in the browser, you will see an empty page. If you open it directly in the browser, you will see an empty page.

View file

@ -1,6 +1,6 @@
body { body {
background-color: black; /* background-color: black;
color: #00FF33; color: #00FF33; */
margin: 1%; margin: 1%;
min-height: 100vh; min-height: 100vh;
} }

View file

@ -1,12 +1,14 @@
import React, { useContext, useEffect, useState } from "react"; import React, { useContext, useEffect, useState } from "react";
import Chat from "./Chat/Chat"; import Chat from "./Chat/Chat";
import "./App.css"; import "./App.css";
import { LangType, Message } from "./Chat/messageTypes"; import { LangType, Message } from "./type/messageTypes";
import { MessageDisplay } from "./Chat/MessageDisplay"; import { MessageDisplay } from "./MessageDisplay/MessageDisplay";
import strings from "./Intl/strings.json"; import strings from "./Intl/strings.json";
import { LangContext, LoginContext, LoginType } from "./context"; import { LangContext, LoginContext, LoginType } from "./context";
import { contentTypes, domain, endpoints, port } from "./consts"; import { contentTypes, domain, endpoints, port } from "./consts";
import { Login } from "./Login/Login"; import { Login } from "./Login/Login";
import { Sidebar } from "./Sidebar/Sidebar";
import { Topbar } from "./Topbar/Topbar";
// what we "in the business" call type gymnastics // what we "in the business" call type gymnastics
const Wrapper = (): React.ReactElement => { const Wrapper = (): React.ReactElement => {
const [lang, setLang] = useState<LangType>("en_US"); const [lang, setLang] = useState<LangType>("en_US");
@ -16,11 +18,19 @@ const Wrapper = (): React.ReactElement => {
? `IRC logged in as ${login.username}` ? `IRC logged in as ${login.username}`
: "IRC Chat"; : "IRC Chat";
}, [login]); }, [login]);
const [sidebarEnabled, setSidebarEnabled] = useState(false);
return ( return (
<LangContext.Provider value={lang}> <LangContext.Provider value={lang}>
<h1>{strings[lang].homepage.title}</h1>
<p>{strings[lang].homepage.description}</p>
<LoginContext.Provider value={login}> <LoginContext.Provider value={login}>
<Topbar
setSidebarEnable={(enabled: boolean) =>
setSidebarEnabled(enabled)
}
></Topbar>
<Sidebar
isEnabled={sidebarEnabled}
setEnable={(enabled: boolean) => setSidebarEnabled(enabled)}
></Sidebar>
{/* callbacks for altering the Lang/Login contexts */} {/* callbacks for altering the Lang/Login contexts */}
<Login <Login
setLogin={(value) => { setLogin={(value) => {
@ -42,7 +52,7 @@ const Wrapper = (): React.ReactElement => {
}; };
const setNameOnServer = async (name: string) => { const setNameOnServer = async (name: string) => {
const responseRaw = await fetch( const responseRaw = await fetch(
`http://${domain}:${port}${endpoints.user}`, `https://${domain}:${port}${endpoints.user}`,
{ {
method: "POST", method: "POST",
mode: "cors", mode: "cors",
@ -67,38 +77,10 @@ const App = ({
}: { }: {
changeLang: (value: string) => void; changeLang: (value: string) => void;
}): React.ReactElement => { }): React.ReactElement => {
const [username, setUsername] = useState<string>();
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const login = useContext(LoginContext); const login = useContext(LoginContext);
const lang = useContext(LangContext); const lang = useContext(LangContext);
const home = strings[lang].homepage; const home = strings[lang].homepage;
// TODO refine setName logic -- move to Login handler
const setNamePrompt = () => {
var newName = prompt(home.userNamePrompt) as string;
while (!validateName(newName)) {
console.log(newName);
prompt("Username invalid! Please enter again.") as string;
}
setNameOnServer(newName).then((value) => {
if (!value.success) {
alert(value.reason);
return true;
} else {
setUsername(newName);
return false;
}
});
};
// if (!username) {
// var newName = prompt(home.userNamePrompt) as string;
// while (!validateName(newName)) {
// console.log(newName);
// prompt("Username invalid! Please enter again.") as string;
// }
// setUsername(newName);
// }
if (!login) { if (!login) {
return <></>; return <></>;
} else } else
@ -125,7 +107,7 @@ const App = ({
}) })
.then((responseBody: { success: boolean }) => { .then((responseBody: { success: boolean }) => {
if (responseBody.success) { if (responseBody.success) {
setUsername(newUsername as string); // TODO Put new username response true handler method stub
} else { } else {
console.error( console.error(
"Server POST message failed." "Server POST message failed."

View file

@ -1,15 +1,25 @@
.chat-inner-wrapper { .chat-inner-wrapper {
height: 50vh; height: 50vh;
overflow-y:scroll; overflow-y: scroll;
/* overflow-wrap: normal; */ /* overflow-wrap: normal; */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.entry-box { .entry-box {
margin-top: 5px; margin-top: 5px;
} }
.chat { .chat {
/* min-height: 80vh; */ /* float: left; */
position: relative; /* min-height: 80vh; */
} position: relative;
box-shadow:
0 2.8px 2.2px rgba(0, 0, 0, 0.034),
0 6.7px 5.3px rgba(0, 0, 0, 0.048),
0 12.5px 10px rgba(0, 0, 0, 0.06),
0 22.3px 17.9px rgba(0, 0, 0, 0.072),
0 41.8px 33.4px rgba(0, 0, 0, 0.086),
0 100px 80px rgba(0, 0, 0, 0.12)
}

View file

@ -1,129 +1,143 @@
import React, { import React, {
ReactElement, ReactElement,
useContext, useContext,
useEffect, useEffect,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { MessageDisplay } from "./MessageDisplay"; import { MessageDisplay } from "../MessageDisplay/MessageDisplay";
import { Client } from "@stomp/stompjs"; import { Client } from "@stomp/stompjs";
import { Message, MessageType } from "./messageTypes"; import { Message, MessageType } from "../type/messageTypes";
import "./Chat.css"; import "./Chat.css";
import strings from "../Intl/strings.json"; import strings from "../Intl/strings.json";
import { LangContext } from "../context"; import { LangContext } from "../context";
import { connectionAddress, endpoints } from "../consts"; import { connectionAddress, endpoints } from "../consts";
const Chat = ({ user }: { user: string }): React.ReactElement => { const Chat = ({ user }: { user: string }): React.ReactElement => {
const lang = useContext(LangContext); const lang = useContext(LangContext);
const chatPage = strings[lang].chat; const chatPage = strings[lang].chat;
const [messages, setMessages] = useState<ReactElement[]>([]); const [messages, setMessages] = useState<ReactElement[]>([]);
let stompClientRef = useRef( let stompClientRef = useRef(
new Client({ new Client({
brokerURL: connectionAddress, brokerURL: connectionAddress,
}) })
); );
// TODO solve issue with non-static markup // TODO solve issue with non-static markup
stompClientRef.current.onConnect = (frame) => { stompClientRef.current.onConnect = (frame) => {
stompClientRef.current.subscribe(endpoints.subscription, (message) => { stompClientRef.current.subscribe(
console.log(`Collected new message: ${message.body}`); endpoints.subscription,
const messageBody = JSON.parse(message.body) as Message; (message) => {
console.log(messageBody); console.log(
setMessages((message) => { `Collected new message: ${message.body}`
return message.concat([ );
<MessageDisplay const messageBody = JSON.parse(
key={`${messageBody.type}@${messageBody.timeMillis}`} message.body
{...messageBody} ) as Message;
/>, console.log(messageBody);
]); setMessages((message) => {
}); return message.concat([
}); <MessageDisplay
stompClientRef.current.publish({ key={`${messageBody.type}@${messageBody.timeMillis}`}
body: JSON.stringify({ {...messageBody}
type: MessageType.HELLO, />,
fromUserId: user, ]);
toUserId: "everyone", });
content: `${user} has joined the server!`, }
timeMillis: Date.now(), );
}), stompClientRef.current.publish({
destination: endpoints.destination, body: JSON.stringify({
}); type: MessageType.HELLO,
}; fromUserId: user,
toUserId: "everyone",
content: `${user} has joined the server!`,
timeMillis: Date.now(),
}),
destination: endpoints.destination,
});
};
// Generic error handlers
stompClientRef.current.onWebSocketError = (error) => {
console.error("Error with websocket", error);
};
// Generic error handlers stompClientRef.current.onStompError = (frame) => {
stompClientRef.current.onWebSocketError = (error) => { console.error(
console.error("Error with websocket", error); "Broker reported error: " + frame.headers["message"]
}; );
console.error("Additional details: " + frame.body);
};
stompClientRef.current.onStompError = (frame) => { // Button press event handler.
console.error("Broker reported error: " + frame.headers["message"]); const sendData = () => {
console.error("Additional details: " + frame.body); const entryElement: HTMLInputElement = document.getElementById(
}; "data-entry"
) as HTMLInputElement;
// Button press event handler. if (entryElement.value === "") {
const sendData = () => { return;
const entryElement: HTMLInputElement = document.getElementById( }
"data-entry" const messageData: Message = {
) as HTMLInputElement; type: MessageType.MESSAGE,
if (entryElement.value === "") { fromUserId: user,
return; toUserId: "everyone",
} content: entryElement.value,
const messageData: Message = { timeMillis: Date.now(),
type: MessageType.MESSAGE, };
fromUserId: user, console.log(
toUserId: "everyone", `STOMP connection status: ${stompClientRef.current.connected}`
content: entryElement.value, );
timeMillis: Date.now(), stompClientRef.current.publish({
}; body: JSON.stringify(messageData),
console.log( destination: endpoints.destination,
`STOMP connection status: ${stompClientRef.current.connected}` headers: {
); "Content-Type":
stompClientRef.current.publish({ "application/json; charset=utf-8",
body: JSON.stringify(messageData), },
destination: endpoints.destination, });
headers: { entryElement.value = "";
"Content-Type": "application/json; charset=utf-8", };
}, useEffect(() => {
}); // Stomp client is disconnected after each re-render
entryElement.value = ""; // This should be actively avoided
}; stompClientRef.current.activate();
useEffect(() => { return () => {
// Stomp client is disconnected after each re-render stompClientRef.current.deactivate();
// This should be actively avoided };
stompClientRef.current.activate(); }, []);
return () => { // https://www.w3schools.com/jsref/obj_keyboardevent.asp
stompClientRef.current.deactivate(); document.addEventListener("keydown", (ev: KeyboardEvent) => {
}; if (ev.key === "Enter") {
}, []); sendData();
// https://www.w3schools.com/jsref/obj_keyboardevent.asp }
document.addEventListener("keydown", (ev: KeyboardEvent) => { });
if (ev.key === "Enter") { useEffect(() => {
sendData(); try {
} const elem = document.querySelector(
}); ".chat-inner-wrapper"
useEffect(() => { );
try { if (elem) {
const elem = document.querySelector(".chat-inner-wrapper"); elem.scrollTop = elem.scrollHeight;
if (elem) { } else {
elem.scrollTop = elem.scrollHeight; }
} else { } catch (err) {
} console.log("error encountered");
} catch (err) { }
console.log("error encountered"); return () => {};
} }, [messages]);
return () => {}; return (
}, [messages]); <fieldset className="chat">
return ( <legend>
<fieldset className="chat"> {chatPage.window.title.replaceAll(
<legend> "$userName",
Logged in as <b>{user}</b> user
</legend> )}
<div className="chat-inner-wrapper">{messages}</div> </legend>
<span className="entry-box"> <div className="chat-inner-wrapper">{messages}</div>
<input id="data-entry"></input> <span className="entry-box">
<button onClick={() => sendData()}> <input id="data-entry"></input>
{chatPage.sendButtonPrompt} <button onClick={() => sendData()}>
</button> {chatPage.sendButtonPrompt}
</span> </button>
</fieldset> </span>
); </fieldset>
);
}; };
export default Chat; export default Chat;

View file

@ -1,3 +0,0 @@
.msg .msg-err {
overflow-wrap: break-word;
}

View file

@ -1,51 +0,0 @@
export const enum MessageType {
MESSAGE = "MESSAGE",
CHNAME = "CHNAME",
HELLO = "HELLO",
DATA = "DATA",
}
export enum SystemMessageCode {
REQ,
RES,
ERR,
}
export type HistoryFetchResult = {
count: number;
items: Array<ChatMessage>;
};
export type ErrorResult = {
text: string;
};
export type TimestampSendRequest = {
ts: number;
};
export type SystemMessage = {
code: SystemMessageCode;
data: HistoryFetchResult | ErrorResult | TimestampSendRequest;
};
export type ChatMessage = {
fromUserId: string;
toUserId: string;
content: string;
timeMillis: number;
};
export type HelloMessage = {
fromUserId: string;
timeMillis: number;
};
export type DataMessage = {};
export type Message = {
type: MessageType;
// data: SystemMessage | ChatMessage | HelloMessage
fromUserId: string;
toUserId: string;
content: string;
timeMillis: number;
};
export const acceptedLangs = [
"en_US",
"zh_TW",
"el_GR",
"ar_SA"
] as const;
export type LangType = (typeof acceptedLangs)[number];

View file

@ -1,7 +0,0 @@
export type User = {
id: number;
userName: string;
dateJoined: number;
lastSeen: number;
passwordHash: string;
};

View file

@ -1,64 +1,136 @@
{ {
"variableNames": ["userName", "content"], "variableNames": ["userName", "content"],
"acceptedLangs": ["en_US", "zh_TW", "el_GR"], "acceptedLangs": ["en_US", "zh_TW", "el_GR", "ar_SA"],
"en_US": { "en_US": {
"homepage": { "homepage": {
"userNamePrompt": "Your username: ", "userNamePrompt": "Your username: ",
"title": "LAN Chat Server", "title": "LAN Chat Server",
"description": "This web application was built for the purposes of an EPQ project.", "description": "This web application was built for the purposes of an EPQ project.",
"copyrightText": "Copyright 2024 - 2025 Zhongheng Liu @ Byron College", "copyrightText": "Copyright 2024 - 2025 Zhongheng Liu @ Byron College",
"switchLang": "Switch Language", "switchLang": "Switch Language",
"newLangPrompt": "Input your ISO-639_ISO-3166 language-contry code below - for example: \"en_US\": " "newLangPrompt": "Input your ISO-639_ISO-3166 language-contry code below - for example: \"en_US\": "
}, },
"chat": { "chat": {
"sendButtonPrompt": "Send", "window": {
"serverMessage": "Message from $userName: $content", "title": "Logged in as $userName"
"joinMessage": "$userName joined the server" },
} "sendButtonPrompt": "Send",
}, "serverMessage": "Message from $userName: $content",
"zh_TW": { "joinMessage": "$userName joined the server"
"homepage": { },
"userNamePrompt": "您的用戶名: ", "login": {
"title": "本地聊天伺服器", "error": {
"description": "該網絡伺服器應用程式專爲Edexcel Level 3 EPQ而製作", "unameTakenOrInvalid": "Username is taken or invalid!",
"copyrightText": "版權所有 2024 - 2025 Zhongheng Liu @ Byron College", "unameNotExists": "Username does not exist!",
"switchLang": "切換語言", "passwdInvalid": "Password incorrect!"
"newLangPrompt": "在下方輸入您想使用的語言的ISO-639_ISO-3166組合語言代碼 - 例如:\"en_US\"" },
}, "window": {
"chat": { "title": "Login window",
"sendButtonPrompt": "發送", "uname": "Username: ",
"serverMessage": "來自 $userName 的訊息:$content", "login": "Login",
"joinMessage": "$userName 加入了伺服器!" "passwd": "Password: ",
} "register": "Register",
}, "logout": "Logout"
"el_GR": { }
"homepage": { }
"userNamePrompt": "το όνομα χρήστη σας: ", },
"title": "Διακομιστής τοπικού δικτύου συνομιλίας", "zh_TW": {
"description": "Αυτή η διαδικτυακή εφαρμογή δημιουργήθηκε για τους σκοπούς ενός έργου EPQ.", "homepage": {
"copyrightText": "Πνευματικά δικαιώματα 2024 - 2025 Zhongheng Liu @ Byron College", "userNamePrompt": "您的用戶名: ",
"switchLang": "Αλλαγή γλώσσας", "title": "本地聊天伺服器",
"newLangPrompt": "Εισαγάγετε τον κωδικό γλώσσας-χώρας ISO-639 ISO-3166 παρακάτω - για παράδειγμα: \"en_US\":" "description": "該網絡伺服器應用程式專爲Edexcel Level 3 EPQ而製作",
}, "copyrightText": "版權所有 2024 - 2025 Zhongheng Liu @ Byron College",
"chat": { "switchLang": "切換語言",
"sendButtonPrompt": "Στείλετε", "newLangPrompt": "在下方輸入您想使用的語言的ISO-639_ISO-3166組合語言代碼 - 例如:\"en_US\""
"joinMessage": "$userName έγινε μέλος του διακομιστή", },
"serverMessage": "μήνυμα από $userName: $content" "chat": {
} "window": {
}, "title": "當前以 $userName 登入"
"ar_SA": { },
"homepage": { "sendButtonPrompt": "發送",
"userNamePrompt": "اسم المستخدم الخاص بك:", "serverMessage": "來自 $userName 的訊息:$content",
"title": "خادم الدردشة LAN", "joinMessage": "$userName 加入了伺服器!"
"description": "تم إنشاء تطبيق الويب هذا لأغراض مشروع EPQ.", },
"copyrightText": "حقوق الطبع والنشر 2024 - 2025 Zhongheng Liu @ كلية بايرون", "login": {
"switchLang": "تبديل اللغة", "error": {
"newLangPrompt": "أدخل رمز اللغة ISO-639_ISO-3166 أدناه - على سبيل المثال: \"en_US\":" "unameTakenOrInvalid": "用戶名已存在或不合規!",
}, "unameNotExists": "該用戶不存在!",
"chat": { "passwdInvalid": "密碼不正確!"
"sendButtonPrompt": "يرسل", },
"joinMessage": "$userName انضم إلى الخادم", "window": {
"serverMessage": "رسالة من $userName: $content" "title": "登入窗口",
} "uname": "用戶名:",
} "login": "登入",
"passwd": "密碼:",
"register": "註冊",
"logout": "登出"
}
}
},
"el_GR": {
"homepage": {
"userNamePrompt": "το όνομα χρήστη σας: ",
"title": "Διακομιστής τοπικού δικτύου συνομιλίας",
"description": "Αυτή η διαδικτυακή εφαρμογή δημιουργήθηκε για τους σκοπούς ενός έργου EPQ.",
"copyrightText": "Πνευματικά δικαιώματα 2024 - 2025 Zhongheng Liu @ Byron College",
"switchLang": "Αλλαγή γλώσσας",
"newLangPrompt": "Εισαγάγετε τον κωδικό γλώσσας-χώρας ISO-639 ISO-3166 παρακάτω - για παράδειγμα: \"en_US\":"
},
"chat": {
"window": {
"title": "Συνδεδεμένος ως $userName"
},
"sendButtonPrompt": "Στείλετε",
"joinMessage": "$userName έγινε μέλος του διακομιστή",
"serverMessage": "μήνυμα από $userName: $content"
},
"login": {
"error": {
"unameTakenOrInvalid": "Το όνομα χρήστη έχει ληφθεί ή δεν είναι έγκυρο!",
"unameNotExists": "Το όνομα χρήστη δεν υπάρχει!",
"passwdInvalid": "Λάθος κωδικός!"
},
"window": {
"title": "Παράθυρο σύνδεσης",
"uname": "Όνομα χρήστη: ",
"login": "Σύνδεση",
"passwd": "Κωδικός πρόσβασης: ",
"register": "Εγγραφή",
"logout": "Αποσύνδεση"
}
}
},
"ar_SA": {
"homepage": {
"userNamePrompt": "اسم المستخدم الخاص بك:",
"title": "خادم الدردشة LAN",
"description": "تم إنشاء تطبيق الويب هذا لأغراض مشروع EPQ.",
"copyrightText": "حقوق الطبع والنشر 2024 - 2025 Zhongheng Liu @ كلية بايرون",
"switchLang": "تبديل اللغة",
"newLangPrompt": "أدخل رمز اللغة ISO-639_ISO-3166 أدناه - على سبيل المثال: \"en_US\":"
},
"chat": {
"window": {
"title": "Logged in as $userName"
},
"sendButtonPrompt": "يرسل",
"joinMessage": "$userName انضم إلى الخادم",
"serverMessage": "رسالة من $userName: $content"
},
"login": {
"error": {
"unameTakenOrInvalid": "Username is taken or invalid!",
"unameNotExists": "Username does not exist!",
"passwdInvalid": "Password incorrect!"
},
"window": {
"title": "Login window",
"uname": "Username: ",
"login": "Login",
"passwd": "Password: ",
"register": "Register",
"logout": "Logout"
}
}
}
} }

View file

@ -1,3 +1,4 @@
.uname-error-text { .uname-error-text {
color: red; color: red;
} }
.login {}

View file

@ -1,8 +1,11 @@
import { useState } from "react"; import { useContext, useState } from "react";
import { contentTypes, domain, endpoints, port } from "../consts"; import { contentTypes, domain, endpoints, port } from "../consts";
import { LoginType } from "../context"; import { LangContext, LoginType } from "../context";
import { User } from "../Chat/userTypes"; import { AuthData, User } from "../type/userTypes";
import "./Login.css"; import "./Login.css";
import strings from "../Intl/strings.json";
import { ECDH } from "crypto";
import { digestMessage } from "../crypto";
const encrypt = (rawPasswordString: string) => { const encrypt = (rawPasswordString: string) => {
// TODO Encryption method stub // TODO Encryption method stub
return rawPasswordString; return rawPasswordString;
@ -12,128 +15,142 @@ export const Login = ({
}: { }: {
setLogin: (newLogin: LoginType | undefined) => void; setLogin: (newLogin: LoginType | undefined) => void;
}): React.ReactElement => { }): React.ReactElement => {
const [valid, setValid] = useState<boolean | undefined>(true); const [valid, setValid] = useState<boolean | undefined>(undefined);
const [validText, setValidText] = useState<string | undefined>(); const [validText, setValidText] = useState<string | undefined>();
const lang = useContext(LangContext);
const loginPage = strings[lang].login;
// TODO mk unit test
const registrationHandler = () => { const registrationHandler = () => {
const uname = (document.getElementById("username") as HTMLInputElement) const uname = (document.getElementById("username") as HTMLInputElement)
.value; .value;
const passwd = encrypt( digestMessage((document.getElementById("passwd") as HTMLInputElement).value).then((passwd) => {
(document.getElementById("passwd") as HTMLInputElement).value fetch(`https://${domain}:${port}${endpoints.register}`, {
); method: "POST",
fetch(`http://${domain}:${port}${endpoints.user}`, { mode: "cors",
method: "POST", headers: contentTypes.json,
mode: "cors", body: JSON.stringify({
headers: contentTypes.json, userName: uname,
body: JSON.stringify({ newUserPassword: passwd,
userName: uname, }),
dateJoined: Date.now(), })
passwordHash: passwd, .then((res) => res.json()).then((body) => {
}), const response = body as AuthData;
}).then((response) => { if (response.exists && !response.success) {throw new Error(loginPage.error.unameTakenOrInvalid)}
if (response.status === 400) { getProfile(uname).then((user) => {
// 400 Bad request setValid(true);
console.log("Username is taken or invalid!"); const futureDate = new Date();
setValid(false); futureDate.setHours(futureDate.getHours() + 2);
setValidText("Username is taken or invalid!"); setLogin({
} else if (response.status === 200) { username: user.userName,
// 200 OK lastSeen: Date.now(),
const futureDate = new Date(); validUntil: futureDate.getUTCMilliseconds(),
futureDate.setHours(futureDate.getHours() + 2); });
setLogin({ document.title = `IRC User ${user.userName}`;
username: uname,
lastSeen: Date.now(),
validUntil: futureDate.getUTCMilliseconds(),
}); });
document.title = `IRC User ${uname}`; })
} .catch((error: Error) => {
setValid(false);
setValidText(error.message);
})
}); });
}; };
// TODO Make unit test
const getProfile = async(userName: string): Promise<User> => {
const res = await (await fetch(`https://${domain}:${port}${endpoints.user}?name=${userName}`,
{
method: "GET",
mode: "cors"
}
)).json()
return res;
}
// login button press handler // login button press handler
// TODO make unit test
const loginHandler = () => { const loginHandler = () => {
const uname = (document.getElementById("username") as HTMLInputElement) const uname = (document.getElementById("username") as HTMLInputElement)
.value; .value;
const passwd = encrypt( digestMessage(
(document.getElementById("passwd") as HTMLInputElement).value (document.getElementById("passwd") as HTMLInputElement).value
); ).then((passwd) => {
// async invocation of Fetch API // async invocation of Fetch API
fetch(`http://${domain}:${port}${endpoints.user}?name=${uname}`, { fetch(`https://${domain}:${port}${endpoints.auth}`, {
method: "GET", method: "POST",
mode: "cors", mode: "cors",
}) headers: {"Content-Type": "application/json"},
.then((res) => { body: JSON.stringify({
if (res.status === 404) { userName: uname,
console.log("404 not found encountered"); userPasswordHash: passwd,
throw new Error("Username does not exist"); })
} else if (res.status === 200) {
console.log("200 OK");
}
return res.json();
}) })
.then((userObject) => { .then(res => res.json())
if (!userObject) { .then((body) => {
return; const response = body as AuthData;
} if (!response.exists) {throw new Error(loginPage.error.unameNotExists)}
const user = userObject as User; else if (!response.success) {throw new Error(loginPage.error.passwdInvalid)}
const validLogin = passwd === user.passwordHash; else {
if (!validLogin) { getProfile(uname).then((user) => {
// login invalid // login valid
throw new Error("Password incorrect!"); setValid(true);
} else { const validUntilDate: Date = new Date();
// login valid validUntilDate.setHours(validUntilDate.getHours() + 2);
const validUntilDate: Date = new Date(); setLogin({
validUntilDate.setHours(validUntilDate.getHours() + 2); username: user.userName,
setLogin({ lastSeen: user.lastSeen,
username: user.userName, validUntil: validUntilDate.getUTCMilliseconds(),
lastSeen: user.lastSeen, });
validUntil: validUntilDate.getUTCMilliseconds(), document.title = `IRC User ${uname}`;
}); });
document.title = `IRC User ${uname}`; }
} })
}) .catch((reason: Error) => {
.catch((reason: Error) => { setValid(false);
setValid(false); setValidText(reason.message);
setValidText(reason.message); });
}); });
}; };
return ( return (
<div className="login"> <div className="login">
<fieldset> <fieldset>
<legend>Login window</legend> <legend>{loginPage.window.title}</legend>
<p className="uname-error-text"> <p className="uname-error-text">
{valid && valid !== undefined ? "" : validText} {!valid && valid !== undefined ? validText : ""}
</p> </p>
<label htmlFor="username">Username: </label> <label htmlFor="username">{loginPage.window.uname}</label>
<br /> <br />
<input id="username" type="text"></input> <input id="username" type="text"></input>
<br /> <br />
<label htmlFor="passwd">Password: </label> <label htmlFor="passwd">{loginPage.window.passwd}</label>
<br /> <br />
<input id="passwd" type="password"></input> <input id="passwd" type="password"></input>
<br /> <br />
<button <button
disabled={valid}
type="submit" type="submit"
onClick={() => { onClick={() => {
loginHandler(); loginHandler();
}} }}
> >
Login {loginPage.window.login}
</button> </button>
<button <button
disabled={valid}
type="submit" type="submit"
onClick={() => { onClick={() => {
registrationHandler(); registrationHandler();
}} }}
> >
Register {loginPage.window.register}
</button> </button>
<button <button
disabled={!valid}
type="submit" type="submit"
onClick={() => { onClick={() => {
setLogin(undefined); setLogin(undefined);
setValid(false); setValid(undefined);
}} }}
> >
Logout {loginPage.window.logout}
</button> </button>
</fieldset> </fieldset>
</div> </div>

View file

@ -0,0 +1,3 @@
.msg .msg-err {
overflow-wrap: break-word;
}

View file

@ -1,8 +1,9 @@
import React, { useContext } from "react"; import React, { useContext } from "react";
import { Message, MessageType } from "./messageTypes"; import { Message, MessageType } from "../type/messageTypes";
import { LangContext } from "../context"; import { LangContext } from "../context";
import strings from "../Intl/strings.json"; import strings from "../Intl/strings.json";
import "./MessageDisplay.css"; import "./MessageDisplay.css";
import { queryByRole } from "@testing-library/react";
export const MessageDisplay = ({ export const MessageDisplay = ({
type, type,
fromUserId, fromUserId,
@ -26,7 +27,11 @@ export const MessageDisplay = ({
dateTime.getHours() > 12 dateTime.getHours() > 12
? dateTime.getHours() - 12 ? dateTime.getHours() - 12
: dateTime.getHours() : dateTime.getHours()
}:${dateTime.getMinutes()} ${dateTime.getHours() > 12 ? "PM" : "AM"}`; }:${
dateTime.getMinutes() >= 10
? dateTime.getMinutes()
: `0${dateTime.getMinutes().toString()}`
} ${dateTime.getHours() > 12 ? "PM" : "AM"}`;
switch (type) { switch (type) {
case MessageType.HELLO as MessageType: case MessageType.HELLO as MessageType:
return ( return (

View file

@ -0,0 +1,5 @@
import {Message, MessageType } from "../type/messageTypes"
export const dataMsgHandler = (msg: Message) => {
if (msg.type !== MessageType.DATA) {return <></>}
}
export const chnameMsgHandler = () => {}

View file

@ -0,0 +1,5 @@
.avatar {
width: 100%;
display: flex;
justify-content:center;
}

View file

@ -0,0 +1,9 @@
import "./Avatar.css";
import placeholderImage from "./placeholder.jpg";
export const Avatar = () => {
return (
<div className="avatar">
<img src={placeholderImage} width="75px"></img>
</div>
);
};

View file

@ -0,0 +1,50 @@
import "../Sidebar.css";
import { Avatar } from "./Avatar";
export const SidebarMenuItem = ({
text,
href,
handler,
}: {
text: string;
href?: string;
handler?: () => void;
}) => {
return (
<div
className="sidebar-menu-item"
onClick={
handler
? () => {
handler();
}
: () => {}
}
>
<li>
<span>
<i>{href ? <a href={href}>{text}</a> : text}</i>
</span>
</li>
<hr></hr>
</div>
);
};
export const SidebarMenu = ({
exitHandler,
}: {
exitHandler: (enabled: boolean) => void;
}) => {
return (
<div className="sidebar-menu">
<Avatar></Avatar>
<SidebarMenuItem text="My Account"></SidebarMenuItem>
<SidebarMenuItem text="Personalisation"></SidebarMenuItem>
<SidebarMenuItem text="Language"></SidebarMenuItem>
<SidebarMenuItem text="Server Configuration"></SidebarMenuItem>
<SidebarMenuItem
text="Return to homepage"
handler={() => exitHandler(false)}
></SidebarMenuItem>
</div>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

47
src/Sidebar/Sidebar.css Normal file
View file

@ -0,0 +1,47 @@
.sidebar {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.5);
animation: fadeIn, 5s;
z-index: 10;
}
.sidebar-menu-item {
margin: 5px;
transition: all 0.5s;
}
.sidebar-menu-item:hover {
background-color: rgba(0, 0, 0, 0.2);
box-shadow: 5px 5px rgba(0, 0, 0, 0.5);
}
.sidebar-content {
background-color: white;
width: max(15%, 200px);
height: 100%;
box-shadow: 10px 10px rgba(0, 0, 0, 0.5);
}
@keyframes fadeIn {
0% {background-color: transparent;}
100% {background-color: rgba(0, 0, 0, 0.5);;}
}
@-moz-keyframes fadeIn {
0% {background-color: transparent;}
100% {background-color: rgba(0, 0, 0, 0.5);;}
}
@-webkit-keyframes fadeIn {
0% {background-color: transparent;}
100% {background-color: rgba(0, 0, 0, 0.5);;}
}
@-o-keyframes fadeIn {
0% {background-color: transparent;}
100% {background-color: rgba(0, 0, 0, 0.5);;}
}
@-ms-keyframes fadeIn {
0% {background-color: transparent;}
100% {background-color: rgba(0, 0, 0, 0.5);;}
}

23
src/Sidebar/Sidebar.tsx Normal file
View file

@ -0,0 +1,23 @@
import "./Sidebar.css";
import { SidebarMenu } from "./Components/SidebarMenu";
export const Sidebar = ({
isEnabled,
setEnable,
}: {
isEnabled: boolean;
setEnable: (enabled: boolean) => void;
}) => {
return isEnabled ? (
<div className="sidebar">
<div className="sidebar-content">
<SidebarMenu
exitHandler={(value) => {
setEnable(value);
}}
></SidebarMenu>
</div>
</div>
) : (
<></>
);
};

10
src/Topbar/Topbar.css Normal file
View file

@ -0,0 +1,10 @@
.topbar {
width: 100vw;
height: 10%;
z-index: 5;
display: flex;
justify-content: baseline;
}
.topbar-span .children {
margin-left: 10px;
}

30
src/Topbar/Topbar.tsx Normal file
View file

@ -0,0 +1,30 @@
import "./Topbar.css";
import strings from "../Intl/strings.json";
import { useContext } from "react";
import { LangContext } from "../context";
import menu from "./menu.png";
export const Topbar = ({
setSidebarEnable,
}: {
setSidebarEnable: (enabled: boolean) => void;
}) => {
const lang = useContext(LangContext);
return (
<div className="topbar">
<img
onClick={() => {
setSidebarEnable(true);
}}
src={menu}
width="100px"
height="100px"
alt="Open Selection Menu"
></img>
<span className="topbar-span">
<h1 className="topbar-span children">
{strings[lang].homepage.title}
</h1>
</span>
</div>
);
};

BIN
src/Topbar/menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -1,11 +1,14 @@
export const domain = window.location.hostname; export const domain = window.location.hostname;
export const port = "8080"; export const port = "8080";
export const connectionAddress = `ws://${domain}:${port}/ws`; export const connectionAddress = `wss://${domain}:${port}/ws`;
export const endpoints = { export const endpoints = {
destination: "/app/chat", destination: "/app/chat",
subscription: "/sub/chat", subscription: "/sub/chat",
history: "/api/v1/msg/", history: "/api/v1/msg/",
user: "/api/v1/user", user: "/api/v1/user",
auth: "/api/v1/auth",
register: "/api/v1/register",
oauth2: "",
}; };
export const contentTypes = { export const contentTypes = {
json: { json: {

View file

@ -1,5 +1,5 @@
import { createContext } from "react"; import { createContext } from "react";
import { LangType } from "./Chat/messageTypes"; import { LangType } from "./type/messageTypes";
export type LoginType = { export type LoginType = {
username: string; username: string;
lastSeen: number; lastSeen: number;

15
src/crypto.ts Normal file
View file

@ -0,0 +1,15 @@
export const ENCRYPTION_TYPE = "SHA-256";
// Implemented according to https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest
export const digestMessage = async (plaintext: string) => {
const textEncoder = new TextEncoder();
const digestArray = Array.from(
new Uint8Array(
await window.crypto.subtle.digest(
ENCRYPTION_TYPE,
textEncoder.encode(plaintext)
)
)
)
return digestArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

View file

@ -1,47 +0,0 @@
import { Client } from "@stomp/stompjs";
import { Message, MessageType } from "../Chat/messageTypes";
const domain = window.location.hostname;
const port = "8080";
const connectionAddress = `ws://${domain}:${port}/ws`;
const endpoints = {
destination: "/app/chat",
subscription: "/sub/chat",
history: "/api/v1/msg/",
};
export const createStompConnection = (
user: string,
subUpdateHandler: (message: Message) => void
) => {
const stompClient = new Client({
brokerURL: connectionAddress,
});
stompClient.onConnect = (frame) => {
stompClient.subscribe(endpoints.subscription, (message) => {
console.log(`Collected new message: ${message.body}`);
const messageBody = JSON.parse(message.body) as Message;
console.log(messageBody);
subUpdateHandler(messageBody);
});
stompClient.publish({
body: JSON.stringify({
type: MessageType.HELLO,
fromUserId: user,
toUserId: "everyone",
content: `${user} has joined the server!`,
timeMillis: Date.now(),
}),
destination: endpoints.destination,
});
};
// Generic error handlers
stompClient.onWebSocketError = (error) => {
console.error("Error with websocket", error);
};
stompClient.onStompError = (frame) => {
console.error("Broker reported error: " + frame.headers["message"]);
console.error("Additional details: " + frame.body);
};
return stompClient;
};

View file

@ -1,13 +1,13 @@
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace; monospace;
} }

47
src/type/messageTypes.ts Normal file
View file

@ -0,0 +1,47 @@
export const enum MessageType {
MESSAGE = "MESSAGE",
CHNAME = "CHNAME",
HELLO = "HELLO",
DATA = "DATA",
}
export enum SystemMessageCode {
REQ,
RES,
ERR,
}
export type HistoryFetchResult = {
count: number;
items: Array<ChatMessage>;
};
export type ErrorResult = {
text: string;
};
export type TimestampSendRequest = {
ts: number;
};
export type SystemMessage = {
code: SystemMessageCode;
data: HistoryFetchResult | ErrorResult | TimestampSendRequest;
};
export type ChatMessage = {
fromUserId: string;
toUserId: string;
content: string;
timeMillis: number;
};
export type HelloMessage = {
fromUserId: string;
timeMillis: number;
};
export type DataMessage = {};
export type Message = {
type: MessageType;
// data: SystemMessage | ChatMessage | HelloMessage
fromUserId: string;
toUserId: string;
content: string;
timeMillis: number;
};
// Type gymnastics to provide dynamic ESLint support
export const acceptedLangs = ["en_US", "zh_TW", "el_GR", "ar_SA"] as const;
export type LangType = (typeof acceptedLangs)[number];

20
src/type/userTypes.ts Normal file
View file

@ -0,0 +1,20 @@
import { URL } from "url";
export type UserAvatar = {
iconUrls: URL[];
};
export type User = {
id: number;
userName: string;
dateJoined: number;
lastSeen: number;
passwordHash: string;
// avatar: UserAvatar;
};
export type AuthData = {
success: boolean;
hasProfile: boolean;
exists: boolean;
authMessage: string;
authResponseTimestampMillis: number;
}