Compare commits

..

27 Commits

Author SHA1 Message Date
0d3c76086b better client for friends 2026-07-24 10:18:30 +10:00
6598162680 I forgot one thing 2026-07-24 09:05:33 +10:00
999a9fe346 update README 2026-07-24 08:39:28 +10:00
1159bdebde Save messages to database 2026-07-23 14:31:13 +10:00
d61946d2ec unread message count + fix bug 2026-07-22 17:34:07 +10:00
abd51e347c start working on friends system 2026-07-22 17:15:05 +10:00
e99d312937 a few qol fixes 2026-07-21 20:04:33 +10:00
dd7e1f8981 Settings 2026-07-21 19:50:36 +10:00
0965749fc8 make profile pages show posts in right order 2026-07-21 17:34:43 +10:00
3a63c68c2d profile pages 2026-07-21 17:31:43 +10:00
09ec5a33ec now you can unlike :( 2026-07-21 17:08:25 +10:00
5d5982c2dc Everyone gets... ONE vote! 2026-07-21 16:58:44 +10:00
7e9271db72 Logins + sessions working 2026-07-21 16:05:08 +10:00
c6a0e91e7e fix 2026-07-21 13:11:20 +10:00
720a101468 Merge pull request 'Update server/src/main.cpp' (#3) from reallynniroprobably/chookchat:reallynniroprobably-patch-1 into master
Reviewed-on: #3
2026-07-21 13:10:19 +10:00
a7dbe2fca3 continue working on login 2026-07-21 13:09:52 +10:00
ec4a9d69de Update server/src/main.cpp 2026-07-21 13:08:59 +10:00
4ce1d56d69 start work on persistent login 2026-07-21 12:06:46 +10:00
cca6737791 Merge pull request 'design navbar for client' (#2) from SpookyDervish/chookchat:master into master
Reviewed-on: #2
2026-07-21 08:02:27 +10:00
68558973f2 design navbar for client 2026-07-21 07:08:49 +10:00
fa20bf7fc4 Fix username html injection 2026-07-20 20:53:33 +10:00
b4750b9053 Support for <meta> attributes 2026-07-20 20:31:25 +10:00
eff5313f26 view specific posts 2026-07-20 20:11:06 +10:00
94b5365a1c likes! 2026-07-20 19:24:50 +10:00
6241824960 keep working on the like button 2026-07-20 19:02:04 +10:00
7d0727a7aa Merge pull request 'make buttons much clearer when hovered' (#1) from SpookyDervish/chookchat:master into master
Reviewed-on: #1
2026-07-20 18:48:37 +10:00
77ceecaf0c make buttons much clearer when hovered 2026-07-20 18:47:45 +10:00
20 changed files with 27411 additions and 46 deletions

View File

@@ -6,6 +6,8 @@ Features:
- Username/password access (passwords secured by bcrypt2) - Username/password access (passwords secured by bcrypt2)
- Markdown rendering - Markdown rendering
- User profile pages
- Friends (direct messages system) (WIP)
## Building ## Building
@@ -24,3 +26,18 @@ meson compile -C builddir
# run with # run with
./builddir/chookchat ./builddir/chookchat
``` ```
## Credits
The server uses the following libraries:
- [cpp-httplib](https://github.com/yhirose/cpp-httplib) for the web server
- [Maddy](https://github.com/progsource/maddy) for the Markdown processor
- [Bcrypt.cpp](https://github.com/hilch/Bcrypt.cpp) for password hashing/salting
- [JSON for Modern C++](https://github.com/nlohmann/json) for JSON processing
- [bin2cpp](https://github.com/end2endzone/bin2cpp) for packing site files into the binary
The following people have contributed to the project:
- [SpookyDervish](https://chookspace.com/SpookyDervish) for client navbar and notification sound
- [reallynniroprobably](https://chookspace.com/reallynniroprobably) for error message

View File

@@ -1,5 +1,20 @@
#!/bin/sh #!/bin/sh
bin2cpp --file=client/footer.html --output=server/src/generated mkdir -p server/src/generated
bin2cpp --file=client/header.html --output=server/src/generated
bin2cpp --file=client/style.css --output=server/src/generated bin2cpp --file=client/header_top.html --output=server/src/generated
bin2cpp --file=client/header_bottom.html --output=server/src/generated
bin2cpp --file=client/footer.html --output=server/src/generated
bin2cpp --file=client/login.html --output=server/src/generated
bin2cpp --file=client/settings.html --output=server/src/generated
bin2cpp --file=client/friends.html --output=server/src/generated
bin2cpp --file=client/e404.html --output=server/src/generated
bin2cpp --file=client/style.css --output=server/src/generated
bin2cpp --file=client/style_friends.css --output=server/src/generated
bin2cpp --file=client/script.js --output=server/src/generated
bin2cpp --file=client/script_friends.js --output=server/src/generated
bin2cpp --file=client/notify.opus --output=server/src/generated

11
client/e404.html Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<title>404</title>
<link rel="stylesheet" type="text/css" href="/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<h1>404 lmao</h1>
</body>
</html>

43
client/friends.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<title>friends</title>
<link rel="stylesheet" type="text/css" href="/style.css">
<link rel="stylesheet" type="text/css" href="/style_friends.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="/script.js"></script>
<script src="/script_friends.js"></script>
</head>
<body>
<nav>
<div id="brand">
<h2><a href="/">chookchat</a></h2>
</div>
<ul>
<li><a id="loginLink" href="/login">login</a></li>
<li><a href="/friends">friends</a></li>
<li><a href="/settings">settings</a></li>
</ul>
</nav>
<div id="friends">
<div id="friends-list">
<div id="friends-header">
<h1>friends</h1>
</div>
<div id="friends-list-links">
<!-- List of friends inserted here -->
</div>
</div>
<div id="friends-messenger">
<div id="friends-messages">
<!-- List of messages inserted here -->
</div>
<div id="friends-message-box">
<textarea id="friends-textarea"></textarea>
<button onclick="sendMessage()">Send</button>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,13 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>posts</title>
<link rel="stylesheet" type="text/css" href="/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head> </head>
<body> <body>
<div class="postbox"> <nav>
<form action="/make_post" method="post"> <div id="brand">
<h2><a href="/">chookchat</a></h2>
</div>
<ul>
<li><a id="loginLink" href="/login">login</a></li>
<li><a href="/friends">friends</a></li>
<li><a href="/settings">settings</a></li>
</ul>
</nav>
<div class="postbox" id="postbox">
<form id="postbox-form" action="/make_post" method="post">
<div> <div>
<label for="username">Username:</label> <label for="username">Username:</label>
<input name="username" id="username" /> <input name="username" id="username" />
@@ -30,6 +36,3 @@
</form> </form>
</div> </div>
<div id="posts"> <div id="posts">
<div id="posts-header">
<h1>posts</h1>
</div>

7
client/header_top.html Normal file
View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>posts</title>
<link rel="stylesheet" type="text/css" href="/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="/script.js"></script>

34
client/login.html Normal file
View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head>
<title>login</title>
<link rel="stylesheet" type="text/css" href="/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="/script.js"></script>
</head>
<body>
<nav>
<div id="brand">
<h2><a href="/">chookchat</a></h2>
</div>
<ul>
<li><a id="loginLink" href="/login">login</a></li>
<li><a href="/friends">friends</a></li>
<li><a href="/settings">settings</a></li>
</ul>
</nav>
<div class="login" id="posts">
<h1>login</h1>
<p>if you haven't registered, enter a username that doesn't exist and hit 'register'</p>
<label for="username">Username:</label>
<input name="username" id="username"></input>
<br>
<label for="password">Password:</label>
<input type="password" name="password" id="password"></input>
<br>
<button onclick="login()">Log In</button>
<button onclick="register()">Register</button>
</div>
</body>
</html>

BIN
client/notify.opus Normal file

Binary file not shown.

230
client/script.js Normal file
View File

@@ -0,0 +1,230 @@
async function like(id) {
const formData = new FormData();
formData.append('post', id);
const result = await fetch('/like', {
method: 'POST',
body: formData
});
if (result.status === 401) {
window.location.href = "/login";
return;
}
if (result.status < 200 || result.status >= 400) {
alert("your post didnt get the like because the server thought your opinion was invalid");
return;
}
}
async function post() {
const postbox = document.getElementById('newpost');
const formData = new FormData();
formData.append('post', postbox.value);
const result = await fetch('/post', {
method: 'POST',
body: formData
});
if (result.status < 200 || result.status >= 400) {
alert("you have been silenced by the server and your post did not go through");
return;
}
window.location.href = "/";
}
async function register() {
const usernameBox = document.getElementById('username');
const passwordBox = document.getElementById('password');
const formData = new FormData();
formData.append('username', usernameBox.value);
formData.append('password', passwordBox.value);
const result = await fetch('/register', {
method: 'POST',
body: formData
});
if (result.status < 200 || result.status >= 400) {
alert(await result.text());
return;
}
window.location.href = "/";
}
async function login() {
const usernameBox = document.getElementById('username');
const passwordBox = document.getElementById('password');
const formData = new FormData();
formData.append('username', usernameBox.value);
formData.append('password', passwordBox.value);
const result = await fetch('/login', {
method: 'POST',
body: formData
});
if (result.status < 200 || result.status >= 400) {
alert("for some reason the server hates you and didn't let you log into your account");
return;
}
window.location.href = "/";
}
async function checkLoginStatus() {
const result = await fetch('/me');
if (result.headers.get('X-Logged-In') !== 'true') {
return {
username: "",
bio: ""
};
}
const obj = {
username: result.headers.get('X-Username'),
bio: result.headers.get('X-Bio')
};
return obj;
}
document.addEventListener('DOMContentLoaded', async function() {
if (window.fetch) {
const userStatus = await checkLoginStatus();
// update nav link / redirect regardless of whether a postbox exists
const loginLink = document.getElementById("loginLink");
if (userStatus.username === "") {
if (window.location.pathname === "/settings" || window.location.pathname === "/friends") {
window.location.href = "/login";
return;
}
} else if (loginLink) {
loginLink.textContent = "hello, " + userStatus.username + "!";
loginLink.href = "/profile/" + userStatus.username;
}
if (userStatus.username !== "" && window.location.pathname === "/settings") {
// fill bio box with the bio
const textarea = document.getElementById("change-bio");
textarea.value = userStatus.bio;
}
const postbox = document.getElementById("postbox");
if (!postbox) {
return;
}
// postbox-specific setup stays here
if (userStatus.username === "") {
const newElement = document.createElement("p");
newElement.textContent = "Welcome to Chookchat!\nTo get posting, click 'login' to log in or create an account.\nEnjoy your stay!";
postbox.appendChild(newElement);
} else {
const helperText = document.createElement("p");
helperText.textContent = "You're posting as " + userStatus.username;
postbox.appendChild(helperText);
const textArea = document.createElement("textarea");
textArea.id = "newpost";
postbox.appendChild(textArea);
const postButton = document.createElement("button");
postButton.textContent = "Post";
postButton.id = "postbutton";
postButton.onclick = post;
postbox.appendChild(postButton);
}
const form = document.getElementById("postbox-form");
if (!form) {
return;
}
form.style.display = 'none';
}
});
async function settingsChangePassword() {
const oldpassword = document.getElementById("change-password-oldpassword");
const newpassword = document.getElementById("change-password");
const formData = new FormData();
formData.append('oldpassword', oldpassword.value);
formData.append('newpassword', newpassword.value);
const result = await fetch('/settings/changePassword', {
method: "POST",
body: formData
});
if (result.status < 200 || result.status >= 400) {
alert("for whatever reason your password wasn't changed");
} else {
alert("your password was changed!");
}
}
async function settingsChangeUsername() {
const password = document.getElementById("change-username-password");
const newusername = document.getElementById("change-username");
const formData = new FormData();
formData.append('password', password.value);
formData.append('newusername', newusername.value);
const result = await fetch('/settings/changeUsername', {
method: "POST",
body: formData
});
if (result.status < 200 || result.status >= 400) {
alert("for whatever reason your username wasn't changed");
} else {
alert("your username was changed!");
}
}
async function settingsChangeBio() {
const bio = document.getElementById("change-bio");
const formData = new FormData();
formData.append('bio', bio.value);
const result = await fetch('/settings/changeBio', {
method: "POST",
body: formData
});
if (result.status < 200 || result.status >= 400) {
alert("for whatever reason your bio wasn't changed");
} else {
alert("your bio was changed!");
}
}
async function invalidateAllSessions() {
const formData = new FormData();
formData.append('a', 'a');
const result = await fetch('/settings/invalidateAllSessions', {
method: "POST",
body: formData
});
if (result.status < 200 || result.status >= 400) {
alert("for whatever reason your sessions weren't invalidated");
} else {
alert("your sessions were invalidated!");
}
}

289
client/script_friends.js Normal file
View File

@@ -0,0 +1,289 @@
class Message {
sender = 0;
content = "";
constructor(sender, content) {
this.sender = sender;
this.content = content;
}
}
// websocket connection
/** @type {WebSocket} */
let socket;
// array of userId's
/** @type {Array<number>} */
let connectedFriends = [];
// map of userId's to arrays of Message objects
/** @type {Map<number, Array<Message>>} */
let recievedMessages = new Map();
// map of userId's to usernames
/** @type {Map<number, string>} */
let usernames = new Map();
// map of userId's to how many unread messages there are
/** @type {Map<number, number>} */
let unreads = new Map();
// userId of the current user who is being talked to
let currentUser = 0;
// userId of the logged-in user (this client). Fetched from /me on load.
// /me responds with headers X-Logged-In, X-Username, X-Bio, X-User-ID
// (no body), so we read the id back out of the response headers.
let selfId = 0;
// notification sound, loaded after ws initialization
let sound;
async function fetchSelfId() {
try {
const res = await fetch("/me");
if (!res.ok) {
console.error("Failed to fetch /me:", res.status);
return;
}
const loggedIn = res.headers.get("X-Logged-In");
if (loggedIn !== "true") {
console.log("Not logged in");
return;
}
const idHeader = res.headers.get("X-User-ID");
selfId = parseInt(idHeader, 10);
} catch (err) {
console.error("Error fetching /me:", err);
}
}
/**
* @param {number} id
* @returns {string}
*/
async function fetchUsername(id) {
try {
const res = await fetch("/userinfo/" + id);
if (!res.ok) {
console.error("Failed to fetch /userinfo/" + id + " :", res.status, await res.text());
return;
}
const usernameHeader = res.headers.get("X-Username");
usernames.set(id, usernameHeader);
return usernameHeader;
} catch (err) {
console.error("Error fetching /me:", err);
}
}
/**
*
* @param {number} id
* @returns {Array<Message>}
*/
async function getMessageHistory(id) {
try {
const res = await fetch("/friends/chatHistory/" + id);
if (!res.ok) {
console.error("Failed to fetch /friends/chatHistory/" + id + " :", res.status, await res.text());
return [];
}
const json = await res.json();
// the server wraps the array in { "messages": [...] }, not a bare array
return json.messages.map(m => new Message(m.sender, m.content));
} catch (err) {
console.error("Error fetching /me:", err);
return [];
}
}
document.addEventListener('DOMContentLoaded', function() {
fetchSelfId();
if (typeof Notification !== "undefined") {
Notification.requestPermission();
}
// create a websocket
socket = new WebSocket("wss://" + document.location.hostname + ":" + document.location.port + "/friends/ws");
socket.onopen = function() {
console.log("connected to server!");
setInterval(() => { // ping the server every 5 seconds
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 3, userId: 0 }));
}
}, 5000);
};
socket.onmessage = async function(event) {
console.log("recieved data", event.data);
const data = JSON.parse(event.data);
switch (data.type) {
case "ok": {
console.log("Server is ok");
break;
}
case "error": {
console.error("Server sent an error:", data.content);
break;
}
case "message": {
console.log("message from", data.userId, ":", data.content);
// update the messages variable
if (recievedMessages.get(data.userId) === undefined) {
// get previously recieved messages
recievedMessages.set(data.userId, await getMessageHistory(data.userId));
}
recievedMessages.get(data.userId).push(new Message(data.userId, data.content));
if (currentUser === data.userId) {
updateMessages();
}
// send a notification to the user
if (document.hidden || currentUser != data.userId) {
if (Notification !== undefined) {
new Notification(usernames.get(data.userId), {body: data.content});
}
if (audio !== undefined) {
audio.play();
}
const currentUnreads = unreads.get(data.userId);
if (currentUnreads === undefined) {
unreads.set(data.userId, 1);
} else {
unreads.set(data.userId, currentUnreads + 1);
}
updateOnlineUsers();
}
break;
}
case "users": {
console.log("updating user list");
connectedFriends = data.content;
updateOnlineUsers();
break;
}
}
}
socket.onerror = function(error) {
console.log(error);
alert("There was an error with the socket! Check console logs for details. Refresh the page to reconnect");
socket.close();
}
socket.onclose = function(event) {
console.log("disconnected from server");
}
audio = new Audio();
audio.preload = "auto";
audio.src = "/notify.opus";
document.body.appendChild(audio);
});
async function updateOnlineUsers() {
const friendsList = document.getElementById("friends-list-links");
friendsList.innerHTML = "";
// Get everyone's username
for (const user of connectedFriends) {
if (usernames.get(user) === undefined) {
await fetchUsername(user);
}
}
for (const user of connectedFriends) {
const newElement = document.createElement("p");
const userUnreads = unreads.get(user);
if (userUnreads !== undefined) {
newElement.textContent = "[" + userUnreads + "] ";
} else {
newElement.textContent = "";
}
newElement.textContent += usernames.get(user);
newElement.onclick = function() {
openUser(user);
}
friendsList.appendChild(newElement);
}
}
/** @param {Message} message */
function renderMessage(message) {
const messagesList = document.getElementById("friends-messages");
const newElement = document.createElement("p");
const who = message.sender === selfId ? "You" : usernames.get(message.sender);
newElement.textContent = who + ": " + message.content;
messagesList.appendChild(newElement);
messagesList.scrollTop = messagesList.scrollHeight;
}
function updateMessages() {
const messages = recievedMessages.get(currentUser);
if (messages === undefined || messages.length === 0) {
return;
}
const message = messages[messages.length - 1];
renderMessage(message);
}
/** @param {number} user */
async function openUser(user) {
currentUser = user;
const messagesList = document.getElementById("friends-messages");
messagesList.innerHTML = "";
if (recievedMessages.get(user) === undefined) {
recievedMessages.set(user, await getMessageHistory(user));
}
// another openUser() call may have run while we were awaiting, and
// switched the user again - bail out if we're no longer viewing this one
if (currentUser !== user) {
return;
}
const messages = recievedMessages.get(user);
for (const message of messages) {
renderMessage(message);
}
unreads.delete(user);
updateOnlineUsers();
}
function sendMessage() {
if (currentUser === 0) {
alert("Select a friend to message first");
return;
}
const textarea = document.getElementById("friends-textarea");
const content = textarea.value.trim();
if (content === "") {
return;
}
if (socket.readyState !== WebSocket.OPEN) {
alert("Not connected to the server. Refresh the page and try again.");
return;
}
socket.send(JSON.stringify({ type: 0, userId: currentUser, content: content }));
// optimistically show the message locally, since the server only
// relays messages between the two other parties, not back to the sender
if (recievedMessages.get(currentUser) === undefined) {
recievedMessages.set(currentUser, []);
}
const sentMessage = new Message(selfId, content);
recievedMessages.get(currentUser).push(sentMessage);
renderMessage(sentMessage);
textarea.value = "";
}

62
client/settings.html Normal file
View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html>
<head>
<title>settings</title>
<link rel="stylesheet" type="text/css" href="/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="/script.js"></script>
</head>
<body>
<nav>
<div id="brand">
<h2><a href="/">chookchat</a></h2>
</div>
<ul>
<li><a id="loginLink" href="/login">login</a></li>
<li><a href="/friends">friends</a></li>
<li><a href="/settings">settings</a></li>
</ul>
</nav>
<div id="posts">
<div id="posts-header">
<h1>settings</h1>
</div>
<div id="password-settings">
<h3>password</h3>
<p>Enter a new password in the box, and click 'change password' to set a new password</p>
<label for="change-password-oldpassword">Old password:</label>
<input id="change-password-oldpassword" type="password"></input>
<br>
<label for="change-password">New password:</label>
<input id="change-password" type="password"></input>
<br>
<button onclick="settingsChangePassword()">Change Password</button>
</div>
<div id="invalidate-sessions">
<h3>invalidate sessions</h3>
<p>this button will invalidate all your sessions, and you will have to log in again on all your devices.</p>
<button onclick="invalidateAllSessions()">Invalidate All Sessions</button>
</div>
<div id="username-settings">
<h3>username</h3>
<p>Enter a new username in the box, and click 'set username' to set a new username</p>
<p>Note: any links to your profile will break! However, any links to your posts will remain.</p>
<label for="change-username">New username:</label>
<input id="change-username"></input>
<br>
<label for="change-username-password">Password:</label>
<input id="change-username-password" type="password"></input>
<br>
<button onclick="settingsChangeUsername()">Change Username</button>
</div>
<div id="bio-settings">
<h3>bio</h3>
<p>set a new bio here!</p>
<textarea id="change-bio"></textarea>
<br>
<button onclick="settingsChangeBio()">Change Bio</button>
</div>
</div>
</body>
</html>

View File

@@ -6,6 +6,8 @@ body {
color: lime; color: lime;
background-color: black; background-color: black;
line-height: 1.1; line-height: 1.1;
height: 100vh;
margin: 0;
} }
hr { hr {
@@ -21,6 +23,12 @@ button, input, textarea {
color: lime; color: lime;
background-color: black; background-color: black;
border: 1px solid lime; border: 1px solid lime;
cursor: pointer;
}
button:hover {
background-color: lime;
color: black;
} }
textarea { textarea {
@@ -49,8 +57,50 @@ img {
box-sizing: border-box; box-sizing: border-box;
} }
.post-buttons {
display: flex;
gap: 5px;
}
a {
color: lime;
text-decoration: underline;
}
nav {
background: rgb(25, 25, 25);
display: flex;
padding: 16px;
margin-bottom: 1em;
#brand h2 {
margin: 0;
}
a {
color: lime;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
ul {
list-style: none;
display: flex;
align-items: center;
margin: 0;
li {
margin-left: 1em;
}
}
}
@media (max-width: 600px) { @media (max-width: 600px) {
input, textarea, button { input, textarea, button {
font-size: 16px; font-size: 16px;
} }
} }

113
client/style_friends.css Normal file
View File

@@ -0,0 +1,113 @@
#friends {
display: flex;
height: calc(100vh - 60px);
overflow: hidden;
}
#friends-list {
width: 280px;
min-width: 280px;
border-right: 1px solid lime;
display: flex;
flex-direction: column;
overflow: hidden;
}
#friends-header {
padding: 16px;
border-bottom: 1px solid lime;
}
#friends-header h1 {
margin: 0;
font-size: 1.3rem;
text-transform: uppercase;
letter-spacing: 2px;
}
#friends-list-links {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
#friends-list-links p {
margin: 0;
padding: 10px 16px;
cursor: pointer;
border-left: 2px solid transparent;
transition: background-color 0.15s, border-color 0.15s;
}
#friends-list-links p:hover {
background-color: rgba(0, 255, 0, 0.1);
border-left-color: lime;
}
#friends-messenger {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
#friends-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
#friends-messages p {
margin: 0;
padding: 4px 0;
word-wrap: break-word;
}
#friends-messages p:empty {
display: none;
}
#friends-message-box {
position: static;
width: 100%;
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid lime;
box-sizing: border-box;
}
#friends-message-box textarea {
flex: 1;
resize: none;
height: 40px;
padding: 8px;
box-sizing: border-box;
}
#friends-message-box button {
padding: 0 20px;
white-space: nowrap;
}
@media (max-width: 600px) {
#friends {
flex-direction: column;
}
#friends-list {
width: 100%;
min-width: unset;
max-height: 200px;
border-right: none;
border-bottom: 1px solid lime;
}
#friends-message-box {
position: static;
width: 100%;
}
}

View File

@@ -4,9 +4,18 @@ sources = [
'src/main.cpp', 'src/main.cpp',
'src/post.cpp', 'src/post.cpp',
'src/db.cpp', 'src/db.cpp',
'src/generated/header.cpp', 'src/generated/e404.cpp',
'src/generated/header_top.cpp',
'src/generated/header_bottom.cpp',
'src/generated/footer.cpp', 'src/generated/footer.cpp',
'src/generated/login.cpp',
'src/generated/settings.cpp',
'src/generated/friends.cpp',
'src/generated/script_friends.cpp',
'src/generated/style.cpp', 'src/generated/style.cpp',
'src/generated/style_friends.cpp',
'src/generated/script.cpp',
'src/generated/notify.cpp',
# bcrypt # bcrypt
'src/bcrypt/bcrypt.cpp', 'src/bcrypt/bcrypt.cpp',

View File

@@ -1,8 +1,32 @@
#include "db.h" #include "db.h"
#include <cstdint>
#include <iomanip>
#include <iostream> #include <iostream>
#include <fstream>
#include <ostream>
#include <sqlite3.h> #include <sqlite3.h>
#include <stdexcept> #include <stdexcept>
#include <sstream> #include <sstream>
#include <ctime>
std::string generateSecureToken(size_t numBytes) {
std::ifstream urandom("/dev/urandom", std::ios::binary);
if (!urandom) {
throw std::runtime_error("couldn't open /dev/urandom");
}
std::vector<unsigned char> buf(numBytes);
urandom.read(reinterpret_cast<char*>(buf.data()), numBytes);
if (!urandom) {
throw std::runtime_error("short read from /dev/urandom");
}
std::stringstream ss;
for (unsigned char b : buf) {
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(b);
}
return ss.str();
}
Database::Database(const std::string& path) { Database::Database(const std::string& path) {
// init db // init db
@@ -15,7 +39,8 @@ Database::Database(const std::string& path) {
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
password TEXT NOT NULL password TEXT NOT NULL,
bio TEXT
); );
CREATE TABLE IF NOT EXISTS posts ( CREATE TABLE IF NOT EXISTS posts (
@@ -23,7 +48,26 @@ Database::Database(const std::string& path) {
userid INTEGER NOT NULL, userid INTEGER NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
timestamp INTEGER NOT NULL, timestamp INTEGER NOT NULL,
likes INTEGER likes INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
userid INTEGER NOT NULL,
expiry INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS likes (
postid INTEGER NOT NULL,
userid INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
senderid INTEGER NOT NULL,
recieverid INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
text TEXT NOT NULL
); );
)"; )";
@@ -72,7 +116,7 @@ std::optional<Post> Database::getPost(uint64_t id) {
std::vector<Post> Database::getUserPosts(uint64_t userId) { std::vector<Post> Database::getUserPosts(uint64_t userId) {
std::stringstream sql; std::stringstream sql;
sql << "SELECT * FROM posts WHERE userid = " << userId << ";"; sql << "SELECT * FROM posts WHERE userid = " << userId << " ORDER BY timestamp DESC LIMIT 100;";
std::string sqlstr = sql.str(); std::string sqlstr = sql.str();
sqlite3_stmt* stmt; sqlite3_stmt* stmt;
@@ -154,6 +198,80 @@ void Database::addPost(const Post& post) {
sqlite3_finalize(stmt); sqlite3_finalize(stmt);
} }
void Database::addLike(uint64_t postId, uint64_t userId) {
// check if the post has already been liked
std::stringstream sql;
sql << "SELECT * FROM likes WHERE postid = " << postId << " AND userId = " << userId << ";";
std::string sqlstr = sql.str();
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(db, sqlstr.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
if (sqlite3_step(stmt) != SQLITE_DONE) {
// the user has UNLIKED something??? impossible
sqlite3_finalize(stmt);
// get rid of their like, how sad
sql.str("");
sql.clear();
sql << "UPDATE posts SET likes = COALESCE(likes, 1) - 1 WHERE id = " << postId << ";";
sqlstr = sql.str();
if (sqlite3_prepare_v2(db, sqlstr.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
if (sqlite3_step(stmt) != SQLITE_DONE) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_finalize(stmt);
// remove like from the likes table :(
sql.str("");
sql.clear();
sql << "DELETE FROM likes WHERE postid = " << postId << " AND userid = " << userId << ";";
sqlstr = sql.str();
if (sqlite3_prepare_v2(db, sqlstr.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
if (sqlite3_step(stmt) != SQLITE_DONE) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_finalize(stmt);
return;
}
sqlite3_finalize(stmt);
// add like to the post
sql.str("");
sql.clear();
sql << "UPDATE posts SET likes = COALESCE(likes, 0) + 1 WHERE id = " << postId << ";";
sqlstr = sql.str();
if (sqlite3_prepare_v2(db, sqlstr.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
if (sqlite3_step(stmt) != SQLITE_DONE) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_finalize(stmt);
// add like to the likes table
sql.str("");
sql.clear();
sql << "INSERT INTO likes (postid, userid) VALUES (" << postId << ", " << userId << ");";
sqlstr = sql.str();
if (sqlite3_prepare_v2(db, sqlstr.c_str(), -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
if (sqlite3_step(stmt) != SQLITE_DONE) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_finalize(stmt);
}
@@ -175,10 +293,17 @@ std::optional<User> Database::getUser(uint64_t id) {
std::string name{reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1))}; std::string name{reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1))};
std::string password{reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2))}; std::string password{reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2))};
const char* bioText = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
std::string bio;
if (bioText == NULL) {
bio = "";
} else {
bio = std::string(bioText);
}
sqlite3_finalize(stmt); sqlite3_finalize(stmt);
return User(id, name, password); return User(id, name, password, bio);
} }
std::optional<User> Database::getUserByName(const std::string& name) { std::optional<User> Database::getUserByName(const std::string& name) {
@@ -199,9 +324,71 @@ std::optional<User> Database::getUserByName(const std::string& name) {
uint64_t id = sqlite3_column_int64(stmt, 0); uint64_t id = sqlite3_column_int64(stmt, 0);
std::string password{reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2))}; std::string password{reinterpret_cast<const char*>(sqlite3_column_text(stmt, 2))};
const char* bioText = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3));
std::string bio;
if (bioText == NULL) {
bio = "";
} else {
bio = std::string(bioText);
}
sqlite3_finalize(stmt); sqlite3_finalize(stmt);
return User(id, name, password); return User(id, name, password, bio);
}
std::optional<std::string> Database::createNewToken(uint64_t id) {
std::string token = generateSecureToken(32);
int64_t expiry = static_cast<int64_t>(std::time(nullptr)) + 60 * 60 * 24 * 30; // 30 days
const char* sql = R"(
INSERT INTO sessions (token, userid, expiry)
VALUES (?, ?, ?);
)";
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_bind_text(stmt, 1, token.c_str(), -1, SQLITE_STATIC);
sqlite3_bind_int64(stmt, 2, id);
sqlite3_bind_int64(stmt, 3, expiry);
if (sqlite3_step(stmt) != SQLITE_DONE) {
sqlite3_finalize(stmt);
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_finalize(stmt);
return token;
}
std::optional<User> Database::getUserByToken(const std::string& token) {
const char* sql = "SELECT userid, expiry FROM sessions WHERE token = ?;";
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_bind_text(stmt, 1, token.c_str(), -1, SQLITE_STATIC);
if (sqlite3_step(stmt) == SQLITE_DONE) {
sqlite3_finalize(stmt);
return {};
}
uint64_t userid = sqlite3_column_int64(stmt, 0);
int64_t expiry = sqlite3_column_int64(stmt, 1);
sqlite3_finalize(stmt);
if (expiry < static_cast<int64_t>(std::time(nullptr))) {
// expired; not bothering to delete it here, an expiry sweep
// job would be a reasonable thing to add later
return {};
}
return getUser(userid);
} }
void Database::addUser(User& user) { void Database::addUser(User& user) {
@@ -225,3 +412,114 @@ void Database::addUser(User& user) {
sqlite3_finalize(stmt); sqlite3_finalize(stmt);
} }
void Database::updateUser(const User& user) {
const char* sql = R"(
UPDATE users
SET
name = ?,
bio = ?,
password = ?
WHERE id = ?;
)";
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_bind_text(stmt, 1, user.name.c_str(), -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 2, user.bio.c_str(), -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 3, user.passwordHash.c_str(), -1, SQLITE_STATIC);
sqlite3_bind_int64(stmt, 4, user.id);
if (sqlite3_step(stmt) != SQLITE_DONE) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_finalize(stmt);
}
void Database::invalidateUserSessions(const User& user) {
const char* sql = R"(
DELETE FROM sessions WHERE userid = ?;
)";
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_bind_int64(stmt, 1, user.id);
if (sqlite3_step(stmt) != SQLITE_DONE) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_finalize(stmt);
}
std::vector<Message> Database::getMessages(uint64_t userA, uint64_t userB, uint64_t amount) {
const char* sql = R"(
SELECT * FROM messages
WHERE (senderid = ? AND recieverid = ?) OR (senderid = ? AND recieverid = ?)
ORDER BY timestamp DESC
LIMIT ?;
)";
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_bind_int64(stmt, 1, userA);
sqlite3_bind_int64(stmt, 2, userB);
sqlite3_bind_int64(stmt, 3, userB);
sqlite3_bind_int64(stmt, 4, userA);
sqlite3_bind_int64(stmt, 5, amount);
std::vector<Message> messages = {};
while (sqlite3_step(stmt) == SQLITE_ROW) {
uint64_t id = sqlite3_column_int64(stmt, 0);
uint64_t senderId = sqlite3_column_int64(stmt, 1);
uint64_t recieverId = sqlite3_column_int64(stmt, 2);
std::time_t timestamp = sqlite3_column_int64(stmt, 3);
const char* textptr = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 4));
std::string text;
if (textptr == NULL) {
text = "";
} else {
text = std::string(textptr);
}
messages.emplace_back(id, senderId, recieverId, text, timestamp);
}
sqlite3_finalize(stmt);
return messages;
}
void Database::addMessage(Message& message) {
const char* sql = R"(
INSERT INTO messages (senderid, recieverid, timestamp, text)
VALUES (?, ?, ?, ?);
)";
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_bind_int64(stmt, 1, message.sender);
sqlite3_bind_int64(stmt, 2, message.reciever);
sqlite3_bind_int64(stmt, 3, message.timestamp);
sqlite3_bind_text(stmt, 4, message.content.c_str(), -1, SQLITE_STATIC);
if (sqlite3_step(stmt) != SQLITE_DONE) {
throw std::runtime_error("sqlite3 error: " + std::string(sqlite3_errmsg(db)));
}
sqlite3_finalize(stmt);
}

View File

@@ -5,17 +5,46 @@
#include <optional> #include <optional>
#include <string> #include <string>
#include <sqlite3.h> #include <sqlite3.h>
#include <sys/types.h>
#include <vector> #include <vector>
#include "post.h" #include "post.h"
void sanitize(std::string& str);
std::string generateSecureToken(size_t numBytes);
struct User { struct User {
uint64_t id = 0; uint64_t id = 0;
std::string name = ""; std::string name = "";
std::string passwordHash = ""; std::string passwordHash = "";
std::string bio = "";
User(uint64_t id, const std::string& name, const std::string& passwordHash) : User(uint64_t id, const std::string& namein, const std::string& passwordHash, const std::string& bio) :
id(id), name(name), passwordHash(passwordHash) {} id(id), passwordHash(passwordHash), bio(bio), name(namein) {}
User(uint64_t id, std::string namein, const std::string& passwordHash, const std::string& bio, int doSanitize) :
id(id), passwordHash(passwordHash), bio(bio) {
sanitize(namein);
name = namein;
}
};
struct Message {
uint64_t id = 0;
uint64_t sender = 0;
uint64_t reciever = 0;
std::time_t timestamp;
std::string content = "";
Message(uint64_t id, uint64_t sender, uint64_t reciever, const std::string& content, std::time_t timestamp) :
id(id), sender(sender), reciever(reciever), content(content), timestamp(timestamp) {}
Message(uint64_t id, uint64_t sender, uint64_t reciever, std::string contentin, std::time_t timestamp, int doSanitize) :
id(id), sender(sender), reciever(reciever), timestamp(timestamp) {
sanitize(contentin);
content = contentin;
}
}; };
class Database { class Database {
@@ -32,12 +61,21 @@ class Database {
std::vector<Post> getUserPosts(uint64_t userId); std::vector<Post> getUserPosts(uint64_t userId);
std::vector<Post> getTopPosts(uint64_t amount); std::vector<Post> getTopPosts(uint64_t amount);
void addPost(const Post& post); void addPost(const Post& post);
void addLike(uint64_t postId, uint64_t userId);
std::optional<User> getUser(uint64_t id); std::optional<User> getUser(uint64_t id);
std::optional<User> getUserByName(const std::string& name); std::optional<User> getUserByName(const std::string& name);
std::optional<User> getUserByToken(const std::string& token);
std::optional<std::string> createNewToken(uint64_t id);
// this will modify the user to have their user ID // this will modify the user to have their user ID
void addUser(User& user); void addUser(User& user);
void updateUser(const User& user);
void invalidateUserSessions(const User& user);
std::vector<Message> getMessages(uint64_t userA, uint64_t userB, uint64_t amount);
// modifies the message to have it's id
void addMessage(Message& message);
}; };

25526
server/src/json/json.hpp Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,215 @@
#include <iostream> #include <cstdint>
#include <ctime>
#include <mutex> #include <mutex>
#include <sstream> #include <sstream>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <unordered_map>
#include <vector> #include <vector>
#include "httplib/httplib.h" #include "httplib/httplib.h"
#include "bcrypt/bcrypt.h" #include "bcrypt/bcrypt.h"
#define JSON_USE_IMPLICIT_CONVERSIONS 0
#include "json/json.hpp"
using Json = nlohmann::json;
#include "db.h" #include "db.h"
#include "post.h" #include "post.h"
#include "generated/header.h" #include "generated/e404.h"
#include "generated/header_top.h"
#include "generated/header_bottom.h"
#include "generated/footer.h" #include "generated/footer.h"
#include "generated/login.h"
#include "generated/settings.h"
#include "generated/friends.h"
#include "generated/script_friends.h"
#include "generated/style.h" #include "generated/style.h"
#include "generated/style_friends.h"
#include "generated/script.h"
#include "generated/notify.h"
std::optional<User> getLoggedInUser(const httplib::Request& request, Database& database) {
if (!request.has_header("Cookie")) {
return {};
}
std::string cookieHeader = request.get_header_value("Cookie");
const std::string key = "session=";
size_t pos = cookieHeader.find(key);
if (pos == std::string::npos) {
return {};
}
pos += key.length();
size_t end = cookieHeader.find(';', pos);
std::string token = cookieHeader.substr(pos, end == std::string::npos ? std::string::npos : end - pos);
return database.getUserByToken(token);
}
int main() { int main() {
const bin2cpp::File& headerfile = bin2cpp::getHeaderHtmlFile(); const bin2cpp::File& headerTopFile = bin2cpp::getHeader_topHtmlFile();
const bin2cpp::File& footerfile = bin2cpp::getFooterHtmlFile(); const bin2cpp::File& headerBottomFile = bin2cpp::getHeader_bottomHtmlFile();
const bin2cpp::File& stylefile = bin2cpp::getStyleCssFile(); const bin2cpp::File& footerfile = bin2cpp::getFooterHtmlFile();
const bin2cpp::File& loginfile = bin2cpp::getLoginHtmlFile();
const bin2cpp::File& settingsfile = bin2cpp::getSettingsHtmlFile();
const bin2cpp::File& friendsfile = bin2cpp::getFriendsHtmlFile();
const bin2cpp::File& friendsjsfile = bin2cpp::getScript_friendsJsFile();
const bin2cpp::File& e404file = bin2cpp::getE404HtmlFile();
const bin2cpp::File& stylefile = bin2cpp::getStyleCssFile();
const bin2cpp::File& friendscssfile = bin2cpp::getStyle_friendsCssFile();
const bin2cpp::File& scriptfile = bin2cpp::getScriptJsFile();
const bin2cpp::File& notifyfile = bin2cpp::getNotifyOpusFile();
std::string header{headerfile.getBuffer(), headerfile.getSize()}; std::string headerTop{headerTopFile.getBuffer(), headerTopFile.getSize()};
std::string headerBottom{headerBottomFile.getBuffer(), headerBottomFile.getSize()};
std::string footer{footerfile.getBuffer(), footerfile.getSize()}; std::string footer{footerfile.getBuffer(), footerfile.getSize()};
std::string login{loginfile.getBuffer(), loginfile.getSize()};
std::string settings{settingsfile.getBuffer(), settingsfile.getSize()};
std::string friends{friendsfile.getBuffer(), friendsfile.getSize()};
std::string friendsjs{friendsjsfile.getBuffer(), friendsjsfile.getSize()};
std::string friendscss{friendscssfile.getBuffer(), friendscssfile.getSize()};
std::string e404{e404file.getBuffer(), e404file.getSize()};
std::string style{stylefile.getBuffer(), stylefile.getSize()}; std::string style{stylefile.getBuffer(), stylefile.getSize()};
std::string script{scriptfile.getBuffer(), scriptfile.getSize()};
std::string notify{notifyfile.getBuffer(), notifyfile.getSize()};
Database database{"chookchat.db"}; Database database{"chookchat.db"};
std::mutex data_mutex; std::mutex data_mutex;
std::unordered_map<uint64_t, httplib::ws::WebSocket*> connectedFriends;
httplib::Server svr; httplib::Server svr;
svr.Get("/style.css", [&style](const httplib::Request& request, httplib::Response& response) { svr.Get("/style.css", [&style](const httplib::Request& request, httplib::Response& response) {
response.set_content(style, "text/css"); response.set_content(style, "text/css");
}); });
svr.Get("/", [&header, &footer, &database, &data_mutex](const httplib::Request& request, httplib::Response& response) { svr.Get("/style_friends.css", [&friendscss](const httplib::Request& request, httplib::Response& response) {
response.set_content(friendscss, "text/css");
});
svr.Get("/script.js", [&script](const httplib::Request& request, httplib::Response& response) {
response.set_content(script, "text/javascript");
});
svr.Get("/script_friends.js", [&friendsjs](const httplib::Request& request, httplib::Response& response) {
response.set_content(friendsjs, "text/javascript");
});
svr.Get("/notify.opus", [&notify](const httplib::Request& request, httplib::Response& response) {
response.set_content(notify, "audio/ogg; codecs=opus");
});
svr.Get("/login", [&login](const httplib::Request& request, httplib::Response& response) {
response.set_content(login, "text/html");
});
svr.Get("/settings", [&settings](const httplib::Request& request, httplib::Response& response) {
response.set_content(settings, "text/html");
});
svr.Get("/friends", [&friends](const httplib::Request& request, httplib::Response& response) {
response.set_content(friends, "text/html");
});
svr.Post("/login", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::string username = request.form.get_field("username");
std::string password = request.form.get_field("password");
std::lock_guard<std::mutex> lock(data_mutex);
try {
std::optional<User> user = database.getUserByName(username);
if (user.has_value()) {
if (!bcrypt::validatePassword(password, user->passwordHash)) {
response.status = 401;
response.set_content("<p>wrong password lmao</p><img src='https://media.tenor.com/wWX7upr7SvwAAAAM/byuntear-cat.gif' alt='your stupid lol'>", "text/html");
return;
}
} else {
response.status = 400;
response.set_content("<p>that username doesn't exist</p>", "text/html");
return;
}
std::optional<std::string> token = database.createNewToken(user->id);
if (!token.has_value()) {
response.status = 500;
response.set_content("<p>couldn't create a session, sorry</p>", "text/html");
return;
}
// HttpOnly so script.js can't read/leak it, SameSite=Lax so it
// isn't sent on cross-site POSTs (basic CSRF mitigation),
// Max-Age matches the 30 day expiry stored in the DB
response.set_header(
"Set-Cookie",
"session=" + *token + "; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000"
);
response.set_redirect("/");
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
svr.Post("/register", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::string username = request.form.get_field("username");
std::string password = request.form.get_field("password");
std::lock_guard<std::mutex> lock(data_mutex);
try {
std::optional<User> user = database.getUserByName(username);
if (user.has_value()) {
response.status = 400;
response.set_content("<p>that username already exists</p>", "text/html");
return;
} else {
if (username.empty()) {
response.status = 400;
response.set_content("<p>hey you can't have an empty username!!!!1!!!1! >:(</p>", "text/html");
return;
}
User newUser{0, username, bcrypt::generateHash(password), "", 1};
database.addUser(newUser);
user = newUser;
}
std::optional<std::string> token = database.createNewToken(user->id);
if (!token.has_value()) {
response.status = 500;
response.set_content("<p>couldn't create a session, sorry</p>", "text/html");
return;
}
// HttpOnly so script.js can't read/leak it, SameSite=Lax so it
// isn't sent on cross-site POSTs (basic CSRF mitigation),
// Max-Age matches the 30 day expiry stored in the DB
response.set_header(
"Set-Cookie",
"session=" + *token + "; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000"
);
response.set_redirect("/");
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
svr.Get("/", [&headerTop, &headerBottom, &footer, &database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::stringstream ss; std::stringstream ss;
ss << header; ss << headerTop << "<meta content='Chookchat' property='og:title' /><meta content='See posts from cool people' property='og:description' />" << headerBottom;
ss << "<div id='posts-header'><h1>posts</h1></div>";
std::lock_guard<std::mutex> lock(data_mutex); std::lock_guard<std::mutex> lock(data_mutex);
@@ -53,11 +225,72 @@ int main() {
response.set_content(ss.str(), "text/html"); response.set_content(ss.str(), "text/html");
} catch (const std::runtime_error& e) { } catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html"); response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} }
}); });
svr.Post("/make_post", [&header, &footer, &database, &data_mutex](const httplib::Request& request, httplib::Response& response) { svr.Get("/posts/:id", [&headerTop, &headerBottom, &footer, &e404, &database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::string postId = request.path_params.at("id");
std::lock_guard<std::mutex> lock(data_mutex);
try {
uint64_t postIdNum = std::stoll(postId);
std::optional<Post> post = database.getPost(postIdNum);
if (!post.has_value()) {
response.status = 404;
response.set_content(e404, "text/html");
return;
}
std::stringstream ss;
ss << headerTop << post->genHtmlMeta() << headerBottom << post->genHtml() << footer;
response.set_content(ss.str(), "text/html");
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
svr.Get("/profile/:username", [&headerTop, &headerBottom, &footer, &e404, &database, &data_mutex](const httplib::Request& request, httplib::Response& response){
std::string username = request.path_params.at("username");
std::lock_guard<std::mutex> lock(data_mutex);
try {
std::optional<User> user = database.getUserByName(username);
if (!user.has_value()) {
response.status = 404;
response.set_content(e404, "text/html");
}
std::vector<Post> posts = database.getUserPosts(user->id);
std::stringstream ss;
ss << headerTop << "<meta content='See posts from " << username << " on Chookchat' property='og:title' />" << headerBottom;
ss << "<div id='posts-header'><h1>" << username << "</h1></div>";
ss << "<p>" << user->bio << "</p>";
for (const auto& post : posts) {
ss << post.genHtml();
}
ss << footer;
response.set_content(ss.str(), "text/html");
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
// endpoint to be used by HTML forms
svr.Post("/make_post", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::string username = request.get_param_value("username"); std::string username = request.get_param_value("username");
std::string password = request.get_param_value("password"); std::string password = request.get_param_value("password");
@@ -81,7 +314,7 @@ int main() {
return; return;
} }
// create user // create user
User newUser{0, username, bcrypt::generateHash(password)}; User newUser{0, username, bcrypt::generateHash(password), ""};
database.addUser(newUser); database.addUser(newUser);
userId = newUser.id; userId = newUser.id;
} }
@@ -90,11 +323,386 @@ int main() {
database.addPost(Post(post, userId)); database.addPost(Post(post, userId));
response.set_redirect("/"); response.set_redirect("/");
} catch (const std::runtime_error& e) { } catch (const std::runtime_error& e) {
response.set_content("<p>there was an error :( it is: " +std::string(e.what()) + "</p>", "text/html"); response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} }
}); });
// endpoint to be used in Javascript
svr.Post("/post", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
try {
std::lock_guard<std::mutex> lock(data_mutex);
std::string post = request.form.get_field("post");
std::optional<User> user = getLoggedInUser(request, database);
if (!user.has_value()) {
response.status = 401;
response.set_content("<p>you're not logged in, so you can't post</p>", "text/html");
return;
}
database.addPost(Post(post, user->id));
response.status = 200;
response.set_content("OK", "text/text");
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
svr.Post("/like", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
try {
std::string post = request.form.get_field("post");
std::lock_guard<std::mutex> lock(data_mutex);
std::optional<User> user = getLoggedInUser(request, database);
if (!user.has_value()) {
response.status = 401;
response.set_content("i dunno that user", "text/plain");
return;
}
uint64_t postNum = std::stoull(post);
database.addLike(postNum, user->id);
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
svr.Get("/me", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::lock_guard<std::mutex> lock(data_mutex);
try {
std::optional<User> user = getLoggedInUser(request, database);
response.set_header("Cache-Control", "no-store");
if (!user.has_value()) {
response.set_header("X-Logged-In", "false");
return;
}
response.set_header("X-Logged-In", "true");
response.set_header("X-Username", user->name);
response.set_header("X-Bio", user->bio);
response.set_header("X-User-ID", std::to_string(user->id));
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
svr.Get("/userinfo/:userid", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::lock_guard<std::mutex> lock(data_mutex);
try {
uint64_t userId = std::stoull(request.path_params.at("userid"));
std::optional<User> user = database.getUser(userId);
response.set_header("Cache-Control", "no-store");
if (!user.has_value()) {
response.status = 404;
return;
}
response.set_header("X-Logged-In", "true");
response.set_header("X-Username", user->name);
response.set_header("X-Bio", user->bio);
response.set_header("X-User-ID", std::to_string(user->id));
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
// friends endpoints
svr.WebSocket("/friends/ws", [&database, &data_mutex, &connectedFriends](const httplib::Request& request, httplib::ws::WebSocket& ws) {
std::optional<User> user = getLoggedInUser(request, database);
if (!user.has_value()) {
ws.close();
return;
}
// add us to the logged in users
connectedFriends[user->id] = &ws;
{
// send out an update to everyone with the new user list
Json data;
data["type"] = "users";
Json list;
for (const auto& [user, value] : connectedFriends) {
list.push_back(user);
}
data["content"] = list;
std::string datadump = data.dump();
for (const auto& [userid, conn] : connectedFriends) {
conn->send(datadump);
}
}
std::string msg;
while (ws.read(msg)) {
try {
/*
* type:
* 0 (message), 1 (friend request), 2 (change status), 3 (ping),
* 4 (get online friends)
* userId: <int>
* content:
* <string> (message),
* (0 (offline), 1 (online) (change status)),
* nil, (friend request)
*/
Json data = Json::parse(msg);
double type = data["type"].get<double>();
uint64_t userId = static_cast<uint64_t>(data["userId"].get<double>());
if (type == 0) { // message
std::string content = data["content"].get<std::string>();
if (connectedFriends.find(userId) == connectedFriends.end()) {
Json data;
data["type"] = "error";
data["content"] = "friend not online";
ws.send(data.dump());
continue;
}
httplib::ws::WebSocket* friendWs = connectedFriends[userId];
if (friendWs->is_open()) {
Json data;
data["type"] = "message";
data["userId"] = user->id;
data["content"] = content;
friendWs->send(data.dump());
Json returnData;
returnData["type"] = "ok";
ws.send(returnData.dump());
}
// Store the message in database
{
std::lock_guard<std::mutex> lock(data_mutex);
Message message{0, user->id, userId, content, std::time(nullptr), 1};
database.addMessage(message);
}
// TODO:
// - Verify users are friends
} else if (type == 1) { // friend request
Json data;
data["type"] = "error";
data["content"] = "not implemented yet";
ws.send(data.dump());
continue;
} else if (type == 2) {
// blank for now
// no need for a ping anymore
} else if (type == 3) {
Json data;
data["type"] = "ok";
ws.send(data.dump());
} else if (type == 4) {
Json data;
data["type"] = "users";
Json list;
for (const auto& [user, value] : connectedFriends) {
list.push_back(user);
}
data["content"] = list;
ws.send(data.dump());
}
} catch (const std::runtime_error& e) {
Json data;
data["type"] = "error";
data["content"] = e.what();
ws.send(data.dump());
}
}
// clean up the connection
connectedFriends.erase(user->id);
ws.close();
// send out an update to everyone with the new user list
{
Json data;
data["type"] = "users";
Json list;
for (const auto& [user, value] : connectedFriends) {
list.push_back(user);
}
data["content"] = list;
std::string datadump = data.dump();
for (const auto& [userid, conn] : connectedFriends) {
conn->send(datadump);
}
}
});
svr.Get("/friends/chatHistory/:userid", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::string userIdStr = request.path_params.at("userid");
std::lock_guard<std::mutex> lock(data_mutex);
try {
std::optional<User> user = getLoggedInUser(request, database);
if (!user.has_value()) {
response.status = 401;
response.set_content("your session is invalid", "text/plain");
return;
}
uint64_t friendUserId = std::stoull(userIdStr);
std::vector<Message> messages = database.getMessages(user->id, friendUserId, 100);
// construct json for response
Json list;
for (auto it = messages.rbegin(); it != messages.rend(); ++it) {
Json message;
message["sender"] = it->sender;
message["reciever"] = it->reciever;
message["id"] = it->id;
message["timestamp"] = it->timestamp;
message["content"] = it->content;
list.push_back(message);
}
Json data;
data["messages"] = list;
response.set_content(data.dump(), "application/json");
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
// settings endpoints
svr.Post("/settings/changePassword", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::string oldpassword = request.form.get_field("oldpassword");
std::string newpassword = request.form.get_field("newpassword");
std::lock_guard<std::mutex> lock(data_mutex);
try {
std::optional<User> user = getLoggedInUser(request, database);
if (!user.has_value()) {
response.status = 401;
response.set_content("your session is invalid", "text/plain");
return;
}
if (!bcrypt::validatePassword(oldpassword, user->passwordHash)) {
response.status = 400;
response.set_content("invalid password", "text/plain");
return;
}
user->passwordHash = bcrypt::generateHash(newpassword);
database.updateUser(*user);
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
svr.Post("/settings/changeUsername", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::string password = request.form.get_field("password");
std::string newusername = request.form.get_field("newusername");
std::lock_guard<std::mutex> lock(data_mutex);
try {
std::optional<User> user = getLoggedInUser(request, database);
if (!user.has_value()) {
response.status = 401;
response.set_content("your session is invalid", "text/plain");
return;
}
if (!bcrypt::validatePassword(password, user->passwordHash)) {
response.status = 400;
response.set_content("invalid password", "text/plain");
return;
}
user->name = newusername;
database.updateUser(*user);
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
svr.Post("/settings/changeBio", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::string bio = request.form.get_field("bio");
std::lock_guard<std::mutex> lock(data_mutex);
try {
std::optional<User> user = getLoggedInUser(request, database);
if (!user.has_value()) {
response.status = 401;
response.set_content("your session is invalid", "text/plain");
return;
}
user->bio = bio;
database.updateUser(*user);
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
svr.Post("/settings/invalidateAllSessions", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::lock_guard<std::mutex> lock(data_mutex);
try {
std::optional<User> user = getLoggedInUser(request, database);
if (!user.has_value()) {
response.status = 401;
response.set_content("your session is invalid", "text/plain");
return;
}
database.invalidateUserSessions(*user);
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
svr.listen("0.0.0.0", 8080); svr.listen("0.0.0.0", 8080);
} }

View File

@@ -13,25 +13,25 @@ void replaceAll(std::string& str, const std::string& from, const std::string& to
} }
} }
void sanitize(std::string& str) {
replaceAll(str, "&", "&amp;");
replaceAll(str, "<", "&lt;");
replaceAll(str, ">", "&gt;");
replaceAll(str, "\"", "&quot;");
replaceAll(str, "'", "&apos;");
}
Post::Post(std::string contentin, const std::string& user) : user(user), time(std::time(nullptr)) { Post::Post(std::string contentin, const std::string& user) : user(user), time(std::time(nullptr)) {
replaceAll(contentin, "&", "&amp;");
replaceAll(contentin, "<", "&lt;");
replaceAll(contentin, ">", "&gt;");
replaceAll(contentin, "\"", "&quot;");
replaceAll(contentin, "'", "&apos;");
// parse the post content markdown // parse the post content markdown
sanitize(contentin);
std::stringstream input(contentin); std::stringstream input(contentin);
std::shared_ptr<maddy::Parser> parser = std::make_shared<maddy::Parser>(); std::shared_ptr<maddy::Parser> parser = std::make_shared<maddy::Parser>();
content = parser->Parse(input); content = parser->Parse(input);
} }
Post::Post(std::string contentin, uint64_t user) : userId(user), time(std::time(nullptr)) { Post::Post(std::string contentin, uint64_t user) : userId(user), time(std::time(nullptr)) {
replaceAll(contentin, "&", "&amp;");
replaceAll(contentin, "<", "&lt;");
replaceAll(contentin, ">", "&gt;");
replaceAll(contentin, "\"", "&quot;");
replaceAll(contentin, "'", "&apos;");
// parse the post content markdown // parse the post content markdown
sanitize(contentin);
std::stringstream input(contentin); std::stringstream input(contentin);
std::shared_ptr<maddy::Parser> parser = std::make_shared<maddy::Parser>(); std::shared_ptr<maddy::Parser> parser = std::make_shared<maddy::Parser>();
content = parser->Parse(input); content = parser->Parse(input);
@@ -47,16 +47,27 @@ std::string Post::genHtml() const {
ts = *localtime(&time); ts = *localtime(&time);
strftime(buf, sizeof(buf), "%a %Y-%m-%d %H:%M:%S %Z", &ts); strftime(buf, sizeof(buf), "%a %Y-%m-%d %H:%M:%S %Z", &ts);
ss << "<p class='postinfo'><i>" << user << "</i> posted this at " << buf << "</p>\n"; ss << "<p class='postinfo'><i><a href='/profile/" << user << "'>" << user << "</a></i> posted this at " << buf << "</p>\n";
ss << content; ss << content;
ss << "<p class='postinfo'>" << likes << " likes</p>"; ss << "<p class='postinfo'>" << likes << " likes</p>";
ss << "<div class='post-buttons'>"; ss << "<div class='post-buttons'>";
ss << "<button onclick='like(" << id << ")'>Like</button>"; ss << "<button onclick='like(\"" << id << "\")' id='likebutton" << id << "'>Like</button>";
ss << "<button onclick='" << id << "'>Open</button>"; ss << "<button onclick='window.location.href = \"/posts/" << id << "\"'>Open</button>";
ss << "</div>"; ss << "</div>";
return ss.str(); return ss.str();
} }
std::regex tags("<[^<]*>");
std::string Post::genHtmlMeta() const {
std::stringstream ss;
ss << "<meta content='" << user << " on Chookchat' property='og:title' />";
ss << "<meta content='" << std::regex_replace(content, tags, "") << "' property='og:description' />";
return ss.str();
}

View File

@@ -17,6 +17,7 @@ struct Post {
uint64_t userId = 0; uint64_t userId = 0;
std::string genHtml() const; std::string genHtml() const;
std::string genHtmlMeta() const;
Post(std::string contentin, const std::string& user); Post(std::string contentin, const std::string& user);
Post(std::string contentin, uint64_t user); Post(std::string contentin, uint64_t user);