From f979fc10c549cc9952ef4b98f66ac0fa1352bb4b Mon Sep 17 00:00:00 2001 From: LatekVon Date: Fri, 29 Sep 2023 04:28:35 +0200 Subject: [PATCH] - embedded images can now be sent to, and stored on the server - updated the apiHandler with a proper deSanitize() function - added Multer middleware to the server as a mean of decoding Form-formatted API requests - completely fixed received message rendering to include all available information - added global request error handling to the server - other fixes --- package.json | 1 + .../chat-interface/chat-interface.module.ts | 4 +- .../text-area/text-area.component.css | 29 +++++++--- .../text-area/text-area.component.html | 11 +++- .../text-area/text-area.component.ts | 19 +++++++ .../shared/services/api-handler.service.ts | 27 ++++++++- .../shared/services/chat-context.service.ts | 18 ++++-- src/server.js | 19 ++++++- src/src/apiCalls.js | 57 +++++++++++++++++++ src/src/multerInstance.js | 5 ++ src/styles.css | 1 + tsconfig.json | 1 + 12 files changed, 170 insertions(+), 22 deletions(-) create mode 100644 src/src/multerInstance.js 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"