Skip to content

Commit

Permalink
- embedded images can now be sent to, and stored on the server
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
latekvo committed Sep 29, 2023
1 parent 626558c commit f979fc1
Show file tree
Hide file tree
Showing 12 changed files with 170 additions and 22 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/app/chat-interface/chat-interface.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -26,7 +27,8 @@ import {MatExpansionModule} from "@angular/material/expansion";
NgOptimizedImage,
MatListModule,
MatButtonModule,
MatExpansionModule
MatExpansionModule,
MatIconModule
],
exports: [
ChatInterfaceComponent
Expand Down
29 changes: 20 additions & 9 deletions src/app/chat-interface/text-area/text-area.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
flex-direction: column;
padding: 0 30px 0 30px;

overflow-x: hidden;
overflow-y: scroll;
height: 81vh;
}
Expand Down Expand Up @@ -82,6 +83,7 @@
.sender-content {
width: fit-content;
padding: 10px;
word-break: break-word;
background-color: var(--basecolor-dark);
border-radius: 4px;
}
Expand All @@ -93,10 +95,11 @@

#input-box {
position: absolute;
left: 0;
right: 0;
bottom: 0;

height: 6vh;
width: 100%;
padding: 10px;

display: flex;
Expand All @@ -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;
}
11 changes: 8 additions & 3 deletions src/app/chat-interface/text-area/text-area.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@
</div>

<div id="input-box" *ngIf="this.chatContextService.storedOpenedChatId.value">
<input id="message-input" [(ngModel)]="messageInput">
<button id="message-send" (click)="sendMessage()" [disabled]="!messageInput">
<img src="" alt="Send">
<!-- https://blog.angular-university.io/angular-file-upload/ -->
<input type="file" class="file-input" style="display: none;" (change)="this.sendImage($event)" #fileUpload>
<button class="input-box-button" (click)="fileUpload.click()">
<mat-icon>attach_file</mat-icon>
</button>
<input id="message-input" [(ngModel)]="messageInput" (keyup.enter)="sendMessage()">
<button class="input-box-button" (click)="sendMessage()" [disabled]="!messageInput">
<mat-icon>send</mat-icon>
</button>
</div>
19 changes: 19 additions & 0 deletions src/app/chat-interface/text-area/text-area.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
});
}
}
}
27 changes: 26 additions & 1 deletion src/app/shared/services/api-handler.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>([
['&lt;', '<'],
['&gt;', '>'],
['&quot;', '"'],
['&#x27;', "'"],
['&#x2F;', '/'],
['&grave;', '`'],
//['&amp;', '&']
]);

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<string, BehaviorSubject<string>> = new Map();
Expand All @@ -17,7 +42,7 @@ export class ApiHandlerService {
this.usernameCache.set(id, new BehaviorSubject<string>(""));
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 => {}
});
Expand Down
18 changes: 12 additions & 6 deletions src/app/shared/services/chat-context.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
})
Expand All @@ -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);
Expand Down
19 changes: 17 additions & 2 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
57 changes: 57 additions & 0 deletions src/src/apiCalls.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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 <img> 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'];
Expand Down
5 changes: 5 additions & 0 deletions src/src/multerInstance.js
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ body {

html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
.round-borders { border-radius: 4px; }
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"allowSyntheticDefaultImports": true,
"lib": [
"ES2022",
"dom"
Expand Down

0 comments on commit f979fc1

Please sign in to comment.