diff --git a/package.json b/package.json
index f4859bb..77c9b73 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
"express": "^4.18.2",
"jsdom": "^22.1.0",
"lodash": "^4.17.21",
+ "multer": "^1.4.5-lts.1",
"rxjs": "~7.8.0",
"sqlite3": "^5.1.6",
"tslib": "^2.3.0",
diff --git a/src/app/chat-interface/chat-interface.module.ts b/src/app/chat-interface/chat-interface.module.ts
index 5c8ef95..900b200 100644
--- a/src/app/chat-interface/chat-interface.module.ts
+++ b/src/app/chat-interface/chat-interface.module.ts
@@ -10,6 +10,7 @@ import { SharedModule } from "../shared/shared.module";
import {MatListModule} from "@angular/material/list";
import {MatButtonModule} from "@angular/material/button";
import {MatExpansionModule} from "@angular/material/expansion";
+import {MatIconModule} from "@angular/material/icon";
@NgModule({
declarations: [
@@ -26,7 +27,8 @@ import {MatExpansionModule} from "@angular/material/expansion";
NgOptimizedImage,
MatListModule,
MatButtonModule,
- MatExpansionModule
+ MatExpansionModule,
+ MatIconModule
],
exports: [
ChatInterfaceComponent
diff --git a/src/app/chat-interface/text-area/text-area.component.css b/src/app/chat-interface/text-area/text-area.component.css
index f2c4f3b..9b322a4 100644
--- a/src/app/chat-interface/text-area/text-area.component.css
+++ b/src/app/chat-interface/text-area/text-area.component.css
@@ -39,6 +39,7 @@
flex-direction: column;
padding: 0 30px 0 30px;
+ overflow-x: hidden;
overflow-y: scroll;
height: 81vh;
}
@@ -82,6 +83,7 @@
.sender-content {
width: fit-content;
padding: 10px;
+ word-break: break-word;
background-color: var(--basecolor-dark);
border-radius: 4px;
}
@@ -93,10 +95,11 @@
#input-box {
position: absolute;
+ left: 0;
+ right: 0;
bottom: 0;
height: 6vh;
- width: 100%;
padding: 10px;
display: flex;
@@ -107,28 +110,36 @@
}
#message-input {
- margin-left: auto;
+ display: inline-block;
height: 95%;
- width: 90%;
+ flex: 1; /* fill remaining width */
+ margin: 0 8px;
}
-#message-send {
- margin: 0 auto 0 10px;
+.input-box-button {
+ position: relative;
+ display: inline-block;
height: 95%;
aspect-ratio: 1/1;
background-color: var(--basecolor-accent);
border: none;
-
transition: background-color 150ms;
}
-#message-send:hover {
+.input-box-button:nth-of-type(2) {
+ left: 0;
+}
+
+.input-box-button:nth-of-type(2) {
+ right: 0;
+}
+
+.input-box-button:hover {
background-color: #de9f49;
cursor: pointer;
-
transition: background-color 50ms;
}
-#message-send:active {
+.input-box-button:active {
background-color: #d08f39;
}
diff --git a/src/app/chat-interface/text-area/text-area.component.html b/src/app/chat-interface/text-area/text-area.component.html
index 6410d18..16603ce 100644
--- a/src/app/chat-interface/text-area/text-area.component.html
+++ b/src/app/chat-interface/text-area/text-area.component.html
@@ -24,8 +24,13 @@
diff --git a/src/app/chat-interface/text-area/text-area.component.ts b/src/app/chat-interface/text-area/text-area.component.ts
index a05f3c9..fe3f594 100644
--- a/src/app/chat-interface/text-area/text-area.component.ts
+++ b/src/app/chat-interface/text-area/text-area.component.ts
@@ -4,6 +4,8 @@ import { HttpClient } from "@angular/common/http";
import { UserContextService } from "../../shared/services/user-context.service";
import { ChatContextService } from "../../shared/services/chat-context.service";
import { PopupHandlerService } from "../../shared/services/popup-handler.service";
+import {ApiHandlerService} from "../../shared/services/api-handler.service";
+import {FileChangeEvent} from "@angular/compiler-cli/src/perform_watch";
// todo: move all network components to a separate 'API interaction' service
@@ -49,4 +51,21 @@ export class TextAreaComponent implements OnInit {
});
this.messageInput = "";
}
+
+ sendImage(event: any) {
+ const image: File = event?.target?.files[0];
+ // to transmit blob files we have to use Form object
+ if (image && this.chatContextService.storedOpenedChatId.value) {
+ console.log(image);
+ let formData = new FormData();
+ formData.append('chatId', this.chatContextService.storedOpenedChatId.value);
+ formData.append('image', image, image.name);
+ this.http.post("/api/sendImage", formData).subscribe({
+ next: () => {},
+ error: response => {
+ this.popupService.dispatchFromResponse(response);
+ }
+ });
+ }
+ }
}
diff --git a/src/app/shared/services/api-handler.service.ts b/src/app/shared/services/api-handler.service.ts
index 52b5554..742178e 100644
--- a/src/app/shared/services/api-handler.service.ts
+++ b/src/app/shared/services/api-handler.service.ts
@@ -9,6 +9,31 @@ import {BehaviorSubject} from "rxjs";
providedIn: 'root'
})
export class ApiHandlerService {
+ // todo: either move this function to a util service or move message fetching here
+ public deSanitize = (input: string) => {
+ // reversal of the server-side function, this theoretically shouldn't be necessary, but angular does some unknown operations in the background which force me to de-sanitize these notations manually
+ const map = new Map([
+ ['<', '<'],
+ ['>', '>'],
+ ['"', '"'],
+ [''', "'"],
+ ['/', '/'],
+ ['`', '`'],
+ //['&', '&']
+ ]);
+
+ const reg = /[&<>"'/`]/ig;
+ return input.replace(reg, (match: string): string => {
+ let res = map.get(match);
+ if (res)
+ return res;
+ else
+ return '';
+
+ //return map.get(match) ?? '';
+ });
+ }
+
constructor(private http: HttpClient) { }
private usernameCache: Map> = new Map();
@@ -17,7 +42,7 @@ export class ApiHandlerService {
this.usernameCache.set(id, new BehaviorSubject(""));
this.http.post<{username: string}>('/api/getUsername', {id: id}).subscribe({
next: response => {
- this.usernameCache.get(id)?.next(response.username);
+ this.usernameCache.get(id)?.next(this.deSanitize(response.username));
},
error: response => {}
});
diff --git a/src/app/shared/services/chat-context.service.ts b/src/app/shared/services/chat-context.service.ts
index 0a8cad5..0d7893a 100644
--- a/src/app/shared/services/chat-context.service.ts
+++ b/src/app/shared/services/chat-context.service.ts
@@ -64,17 +64,22 @@ export class ChatContextService {
}
});
+ let completeMessage = (message: MessageModel) => {
+ if (message.senderId == this.userContextService.storedUserId.value) {
+ message.writtenByMe = true;
+ }
+ message.content = apiHandlerService.deSanitize(message.content);
+ // local storage function, will make requests for each unique id and for duplicates just return the value.
+ // I believe it will be simpler to write a simple store myself than to rely on the ngRx
+ message.senderName = this.apiHandlerService.getUsername(message.senderId);
+ }
+
this.http.post<{ messages: MessageModel[] }>('/api/fetchMessages', {chatId: newChatId, pagination: pagination})
.pipe(
map(body => body.messages.reverse()),
map(messages => {
messages.forEach((message) => {
- if (message.senderId == this.userContextService.storedUserId.value) {
- message.writtenByMe = true;
- }
- // local storage function, will make requests for each unique id and for duplicates just return the value.
- // i believe it will be simpler to write a simple store myself than to rely on the ngRx
- message.senderName = this.apiHandlerService.getUsername(message.senderId);
+ completeMessage(message);
});
return messages;
})
@@ -94,6 +99,7 @@ export class ChatContextService {
this.messageStream.onmessage = (incomingEvent) => {
console.log(`chat-context: received broadcast:`, incomingEvent.data);
let message = JSON.parse(incomingEvent.data);
+ completeMessage(message);
let currentMessages = this.storedMessageList.value;
currentMessages.push(message);
this.storedMessageList.next(currentMessages);
diff --git a/src/server.js b/src/server.js
index f9b4538..17927fb 100644
--- a/src/server.js
+++ b/src/server.js
@@ -2,11 +2,21 @@ const express = require('express');
const app = express();
const path = require('path');
const fs = require('fs');
-const bodyParser = require('body-parser');
+const bodyParser = require('body-parser'); // parser for request body JSON data
+const multer = require('multer'); // parser for FormData data
const cookieParser = require("cookie-parser");
+// we're using a non-standard multer mechanism, essentially saving received files into the memory (sounds scary, but I will ignore my gut),
+// then after saving the image to ram, and processing the request, we move the image from ram to it's destination
+const upload = require('./src/multerInstance');
+
app.use(cookieParser());
-app.use(bodyParser.json());
+app.use(bodyParser.json()); // application/json
+app.use(bodyParser.urlencoded({ extended: true })); // application/x-www-form-urlencoded
+// all of the lines below dictate the only allowed type of form data transmitted globally.
+// app.use(upload.array()); // returns: 500 unexpected field
+// app.use(upload.single('image')); // returns: 500 unexpected field (doesn't see chatId)
+// app.use(upload.any()); // throws: too long to paste, but critical
const databaseStarter = require('./src/databaseStarter');
const databaseService = require('./src/databaseService');
@@ -38,6 +48,11 @@ app.get(/^(?!\/api).*/, function(req, res) {
});
});
+// global stack error handling, should completely mitigate client-caused server crashes
+// DO NOT FIX! Everything here from the app.use() construction to res.status() is unrecognised and marked as error, this is IDE's mistake. DO NOT FIX!
+app.use((err, req, res, next) => {
+ res.status(500).send(err.message);
+});
app.use('/api', apiRouter);
app.listen(port);
diff --git a/src/src/apiCalls.js b/src/src/apiCalls.js
index 061f194..d9d556c 100644
--- a/src/src/apiCalls.js
+++ b/src/src/apiCalls.js
@@ -2,12 +2,15 @@ const dbs = require('./databaseService');
const express = require('express');
const router = express.Router();
+const fs = require('fs');
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom'); // simulates a window
const DOMPurify = createDOMPurify(new JSDOM().window);
+const upload = require('./multerInstance');
const bcrypt = require('bcrypt');
+const path = require("path");
//const {webSocket} = require("rxjs/webSocket");
const PAGE_URL = 'localhost:3000';
@@ -151,6 +154,7 @@ function verifyRequest(req, res, requiredValues = []) {
requiredValues.forEach((key) => {
let value = JSON.stringify(req.body[key]);
if (value === undefined && value !== '') {
+
allValuesPresent = false;
} else {
req.body[key] = sanitizeEntity(req.body[key]);
@@ -254,6 +258,59 @@ router.post('/sendMessage', (req, res) => {
}, () => {});
});
+router.post('/sendImage', upload.fields([{name: 'image', maxCount: 1}, {name: 'chatId', maxCount: 1}]), (req, res) => {
+ // image is not required as it's not a part of the body object. DO NOT FIX!
+ verifyRequest(req, res, ['chatId']).then(userId => {
+ // console.log(req.body); // returns { chatId: string}
+ // console.log(req.files); // returns { image: imageJson }
+ let imageContent = req.files['image'][0];
+ let messageChatId = req.body['chatId'];
+
+ dbs.getRecord(dbs.CHAT_LINKS_TABLE, ['id'], `chatId='${messageChatId}' AND userId='${userId}'`).then(output => {
+ // check if the user is a member of requested chat && size < 10MB
+ if (output?.id && imageContent !== undefined && imageContent?.size <= (Math.pow(2, 20) * 10)) {
+ // rare case, we generate the id here, for better safety we could send the image first and THEN edit it with the appropriate content,
+ // but for now this is sufficient.
+ // todo: this response protocol has a lot of unfinished security, and has to be patched up asap before lauching any production builds.
+ let imageId = Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join(''); // copied from databaseService.js
+ let fileExtension = 'png';
+ // img fields: { fieldname: 'image', originalname: string, encoding: 'Xbit', mimetype: 'image/XXX', buffer: Buffer, size: Number }
+ // todo: out of these ^^^, we want to check mimetype and size
+ // save the image
+ console.log(__dirname)
+ let fileHandle = fs.openSync(path.join(__dirname, '..', 'static', `${imageId}.${fileExtension}`), 'w', 0o660);
+ fs.writeSync(fileHandle, imageContent['buffer']);
+ fs.closeSync(fileHandle);
+
+ let messageInsertionQuery = {
+ // todo: change .png into an autodetected type: jpg jpeg or png
+ // IT LOOKS SIMPLE TO JUST REPLACE [] WITH <> BUT THAT WOULD ENABLE HARD TO PATCH XSS INJECTION, angular handles this well by itself as long as we don't manually force the html into the DOM
+ // instead, we will look for @image() and place what's inside into a carefully injected tag. No user input will get injected,
+ // if user simulates this syntax sanitize() along with angular will pick out any XSS injection attempts
+ id: imageId,
+ dateCreated: newSqlDate(),
+ senderId: userId,
+ chatId: messageChatId,
+ content: `@image(${imageId}.png)`
+ };
+ dbs.insertRecord(dbs.MESSAGES_TABLE, messageInsertionQuery).then(messageId => {
+ let broadCastedMessage = {
+ id: messageId,
+ dateCreated: newSqlDate(), // more readable for now
+ senderId: userId,
+ content: `@image(${imageId}.png)`
+ }
+ broadcastMessage(messageChatId, broadCastedMessage);
+ // no res.end(), that would interfere with broadcastMessage
+ });
+ } else {
+ res.writeHead(401, 'File size is too large'); // most likely error
+ res.end();
+ }
+ });
+ }, () => {});
+});
+
router.post('/fetchMessages', (req, res) => {
verifyRequest(req, res, ['chatId', 'pagination']).then(userId => {
let chatId = req.body['chatId'];
diff --git a/src/src/multerInstance.js b/src/src/multerInstance.js
new file mode 100644
index 0000000..d0b1be4
--- /dev/null
+++ b/src/src/multerInstance.js
@@ -0,0 +1,5 @@
+const multer = require('multer');
+const storage = multer.memoryStorage(); // RAM storage, this is not standard for Multer but we have to process the image before saving it, so it's necessary.
+const upload = multer({ storage: storage });
+
+module.exports = upload;
diff --git a/src/styles.css b/src/styles.css
index c3cf56c..0098a90 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -28,3 +28,4 @@ body {
html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
+.round-borders { border-radius: 4px; }
diff --git a/tsconfig.json b/tsconfig.json
index ed966d4..64a863e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -19,6 +19,7 @@
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
+ "allowSyntheticDefaultImports": true,
"lib": [
"ES2022",
"dom"