59 | {
60 | multimediaContent.map((file, index) => {
61 | return (
62 |
63 | )
64 | })
65 | }
66 |
67 |
68 | {mediaLoading &&
}
69 | >
70 | }
71 |
--------------------------------------------------------------------------------
/frontend-web/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import "./index.css"
3 | import * as serviceWorker from "./serviceWorker"
4 | import {createBrowserRouter, redirect, RouterProvider} from "react-router-dom"
5 | import {createRoot} from "react-dom/client"
6 | import {WebSocketMainComponent} from "./components/websocket/websocket-main-component"
7 | import {VideoComponent} from "./components/websocket/video-component"
8 | import {LoaderProvider} from "./context/loader-context"
9 | import {AlertComponent} from "./components/partials/alert-component"
10 | import {LoaderComponent} from "./components/partials/loader/LoaderComponent"
11 | import {HttpGroupService} from "./service/http-group-service"
12 | import {AlertContextProvider} from "./context/AlertContext"
13 | import {UserContextProvider} from "./context/UserContext"
14 | import {GroupContextProvider} from "./context/GroupContext"
15 | import {SearchProvider} from "./context/SearchContext"
16 | import {LoginWrapperComponent} from "./components/login/LoginWrapperComponent"
17 | import {RegisterUserWrapper} from "./components/register/RegisterUserWrapper"
18 | import {ResetPasswordComponent} from "./components/reset-password/ResetPasswordComponent"
19 |
20 | const router = createBrowserRouter([
21 | {
22 | path: "/",
23 | loader: async () => {
24 | return new HttpGroupService().pingRoute()
25 | .then(() => redirect("/t/messages"))
26 | .catch(() => redirect("/login"))
27 | },
28 | },
29 | {
30 | path: "login",
31 | element:
,
32 | },
33 | {
34 | path: "register",
35 | element:
36 | },
37 | {
38 | path: "t/messages",
39 | element:
40 | },
41 | {
42 | path: "t/messages/:groupId",
43 | element:
44 | },
45 | {
46 | path: "room/:callUrl/:groupUrl",
47 | element:
48 | },
49 | {
50 | path: "reset/password",
51 | element:
52 | }
53 | ])
54 |
55 | function RootComponent() {
56 | return (
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | )
71 | }
72 |
73 | createRoot(document.getElementById("root")!)
74 | .render(
75 |
76 | )
77 |
78 | // If you want your app to work offline and load faster, you can change
79 | // unregister() to register() below. Note this comes with some pitfalls.
80 | // Learn more about service workers: https://bit.ly/CRA-PWA
81 | serviceWorker.unregister()
82 |
--------------------------------------------------------------------------------
/backend/storage/src/main/java/com/mercure/storage/service/StorageService.java:
--------------------------------------------------------------------------------
1 | package com.mercure.storage.service;
2 |
3 | import com.mercure.commons.entity.FileEntity;
4 | import com.mercure.commons.service.FileService;
5 | import com.mercure.commons.utils.StaticVariable;
6 | import com.mercure.storage.config.StorageOptions;
7 | import jakarta.annotation.PostConstruct;
8 | import lombok.AllArgsConstructor;
9 | import org.slf4j.Logger;
10 | import org.slf4j.LoggerFactory;
11 | import org.springframework.stereotype.Service;
12 | import org.springframework.util.StringUtils;
13 | import org.springframework.web.multipart.MultipartFile;
14 | import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
15 |
16 | import java.io.IOException;
17 | import java.io.InputStream;
18 | import java.nio.file.Files;
19 | import java.nio.file.Paths;
20 | import java.nio.file.StandardCopyOption;
21 | import java.util.Objects;
22 | import java.util.UUID;
23 |
24 | @Service
25 | @AllArgsConstructor
26 | public class StorageService {
27 |
28 | private static final Logger log = LoggerFactory.getLogger(StorageService.class);
29 |
30 | private FileService fileService;
31 |
32 | private StorageOptions storageOptions;
33 |
34 | @PostConstruct
35 | public void init() {
36 | try {
37 | Files.createDirectories(Paths.get(StaticVariable.FILE_STORAGE_PATH));
38 | } catch (IOException e) {
39 | log.error("Cannot initialize directory : {}", e.getMessage());
40 | }
41 | }
42 |
43 | public void store(MultipartFile file, int messageId) {
44 | String completeName = StringUtils.cleanPath(Objects.requireNonNull(file.getOriginalFilename()));
45 | String[] array = completeName.split("\\.");
46 | String fileExtension = array[array.length - 1];
47 | String fileName = UUID.randomUUID().toString();
48 |
49 | String newName = fileName + "." + fileExtension;
50 | String uri = ServletUriComponentsBuilder.fromCurrentContextPath()
51 | .path("/uploads/")
52 | .path(newName)
53 | .toUriString();
54 |
55 | FileEntity fileEntity = new FileEntity();
56 | fileEntity.setUrl(uri);
57 | fileEntity.setFilename(fileName);
58 | fileEntity.setMessageId(messageId);
59 | try {
60 | if (file.isEmpty()) {
61 | log.warn("Cannot save empty file with name : {}", newName);
62 | return;
63 | }
64 | if (fileName.contains("..")) {
65 | log.warn("Cannot store file with relative path outside current directory {}", newName);
66 | }
67 | try (InputStream inputStream = file.getInputStream()) {
68 | storageOptions.uploadFile(file.getResource().getFile());
69 | Files.copy(inputStream, Paths.get(StaticVariable.FILE_STORAGE_PATH).resolve(newName),
70 | StandardCopyOption.REPLACE_EXISTING);
71 | fileService.save(fileEntity);
72 | }
73 | } catch (Exception e) {
74 | log.error(e.getMessage());
75 | }
76 | }
77 |
78 | }
79 |
--------------------------------------------------------------------------------
/backend/storage/src/main/java/com/mercure/storage/controller/WsFileController.java:
--------------------------------------------------------------------------------
1 | package com.mercure.storage.controller;
2 |
3 | import com.mercure.commons.dto.MessageDTO;
4 | import com.mercure.commons.dto.OutputTransportDTO;
5 | import com.mercure.commons.entity.MessageEntity;
6 | import com.mercure.core.service.GroupService;
7 | import com.mercure.core.service.MessageService;
8 | import com.mercure.storage.service.StorageService;
9 | import com.mercure.core.service.UserSeenMessageService;
10 | import com.mercure.commons.utils.MessageTypeEnum;
11 | import com.mercure.commons.utils.TransportActionEnum;
12 | import lombok.AllArgsConstructor;
13 | import lombok.extern.slf4j.Slf4j;
14 | import org.springframework.http.MediaType;
15 | import org.springframework.http.ResponseEntity;
16 | import org.springframework.messaging.simp.SimpMessagingTemplate;
17 | import org.springframework.web.bind.annotation.*;
18 | import org.springframework.web.multipart.MultipartFile;
19 |
20 | import java.util.List;
21 |
22 | @RestController
23 | @AllArgsConstructor
24 | @Slf4j
25 | public class WsFileController {
26 |
27 | private MessageService messageService;
28 |
29 | private GroupService groupService;
30 |
31 | private SimpMessagingTemplate messagingTemplate;
32 |
33 | private StorageService storageService;
34 |
35 | private UserSeenMessageService seenMessageService;
36 |
37 | /**
38 | * Receive file to put in DB and send it back to the group conversation
39 | *
40 | * @param file The file to be uploaded
41 | * @param userId int value for user ID sender of the message
42 | * @param groupUrl string value for the group URL
43 | * @return a {@link ResponseEntity} with HTTP code
44 | */
45 | @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
46 | public ResponseEntity> uploadFile(@RequestParam(name = "file") MultipartFile file, @RequestParam(name = "userId") int userId, @RequestParam(name = "groupUrl") String groupUrl) {
47 | int groupId = groupService.findGroupByUrl(groupUrl);
48 | try {
49 | MessageEntity messageEntity = messageService.createAndSaveMessage(userId, groupId, MessageTypeEnum.FILE.toString(), "have send a file");
50 | storageService.store(file, messageEntity.getId());
51 | OutputTransportDTO res = new OutputTransportDTO();
52 | MessageDTO messageDTO = messageService.createNotificationMessageDTO(messageEntity, userId);
53 | res.setAction(TransportActionEnum.NOTIFICATION_MESSAGE);
54 | res.setObject(messageDTO);
55 | seenMessageService.saveMessageNotSeen(messageEntity, groupId);
56 | List
toSend = messageService.createNotificationList(userId, groupUrl);
57 | toSend.forEach(toUserId -> messagingTemplate.convertAndSend("/topic/user/" + toUserId, res));
58 | } catch (Exception e) {
59 | log.error("Cannot save file, caused by {}", e.getMessage());
60 | return ResponseEntity.status(500).build();
61 | }
62 | return ResponseEntity.ok().build();
63 | }
64 |
65 | @GetMapping("files/groupUrl/{groupUrl}")
66 | public List getMultimediaContent(@PathVariable String groupUrl) {
67 | return messageService.getMultimediaContentByGroup(groupUrl);
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/frontend-web/src/context/GroupContext.tsx:
--------------------------------------------------------------------------------
1 | import React, {createContext, Dispatch, useReducer} from "react"
2 | import {GroupModel} from "../interface-contract/group-model"
3 |
4 | enum GroupContextAction {
5 | ADD_GROUP = "ADD_GROUP",
6 | UPDATE_GROUPS = "UPDATE_GROUPS",
7 | UPDATE_LAST_MESSAGE_GROUP = "UPDATE_LAST_MESSAGE_GROUP",
8 | UPDATE_SEEN_MESSAGE = "UPDATE_SEEN_MESSAGE",
9 | SET_GROUPS = "SET_GROUPS",
10 | }
11 |
12 | export type GroupActionType =
13 | | { type: GroupContextAction.UPDATE_GROUPS; payload: { id: number; field: Partial } }
14 | | { type: GroupContextAction.ADD_GROUP; payload: GroupModel }
15 | | { type: GroupContextAction.UPDATE_SEEN_MESSAGE; payload: { groupUrl: string; isMessageSeen: boolean } }
16 | | { type: GroupContextAction.UPDATE_LAST_MESSAGE_GROUP; payload: { groupUrl: string; field: Partial } }
17 | | { type: GroupContextAction.SET_GROUPS; payload: GroupModel[] }
18 |
19 | const GroupContext = createContext<{
20 | groups: GroupModel[]
21 | changeGroupState: Dispatch;
22 | } | undefined>(undefined)
23 |
24 | export const groupReducer = (state: GroupModel[], action: GroupActionType): GroupModel[] => {
25 | switch (action.type) {
26 | case GroupContextAction.UPDATE_GROUPS: {
27 | const index = state.findIndex((group) => group.id === action.payload.id)
28 | if (index >= 0) {
29 | return state
30 | }
31 | return state
32 | }
33 | case GroupContextAction.ADD_GROUP: {
34 | return [action.payload, ...state] // at first index because new conversation
35 | }
36 | case GroupContextAction.UPDATE_LAST_MESSAGE_GROUP: {
37 | const index = state.findIndex((group) => group.url === action.payload.groupUrl)
38 | if (index > -1) {
39 | state[index].lastMessageSender = action.payload.field.lastMessageSender
40 | state[index].lastMessageDate = action.payload.field.lastMessageDate || ""
41 | state[index].lastMessageSeen = action.payload.field.lastMessageSeen || false
42 | state[index].lastMessage = action.payload.field.lastMessage || ""
43 | }
44 | const groupToUpdatePosition = state[index]
45 | state.splice(index, 1)
46 | state.unshift(groupToUpdatePosition)
47 | return state
48 | }
49 | case GroupContextAction.UPDATE_SEEN_MESSAGE: {
50 | const newState = [...state]
51 | const index = newState.findIndex((group) => group.url === action.payload.groupUrl)
52 | if (index > -1) {
53 | newState[index].lastMessageSeen = action.payload.isMessageSeen
54 | }
55 | return newState
56 | }
57 | case GroupContextAction.SET_GROUPS: {
58 | return action.payload
59 | }
60 | default:
61 | return state
62 | }
63 | }
64 |
65 | const GroupContextProvider: React.FC<{ children: React.ReactNode }> = ({children}) => {
66 | const [groups, changeGroupState] = useReducer(groupReducer, [])
67 | return (
68 |
69 | {children}
70 |
71 | )
72 | }
73 |
74 | export {GroupContextProvider, GroupContext, GroupContextAction}
75 |
76 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | 
3 | 
4 | 
5 |
6 | # FastLiteMessage
7 |
8 | Real time chat application group oriented built with React and Spring Boot. Talk with your friends, create and add users to conversation, send messages or images, set groups administrators and start video calls ! (coming soon)
9 |
10 | # Project Requirements
11 |
12 | * [JDK](https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html) 17
13 | * [NodeJS](https://nodejs.org/en/download/) v20.12.1
14 | * [ReactJS](https://reactjs.org/) v18
15 | * [Material UI](https://mui.com/) v5.7.0
16 | * [MySQL Server](https://www.mysql.com/)
17 |
18 | # What's next ?
19 | You want to know the roadmap of the project, next release or next fixes ? Check the "Projects" tab or visit the [Fast Lite Message's roadmap](https://github.com/users/Thibaut-Mouton/projects/4)
20 |
21 | # Project fast start up
22 | In a hurry ? Juste type ```docker-compose up``` in the root directory.
23 | This will start 3 containers : MySQL, backend and frontend together. Liquibase will take care of the database setup. For development purpose, the DB is filled with 5 accounts (password: ```root```) :
24 | * Thibaut
25 | * Mark
26 | * John
27 | * Luke
28 | * Steve
29 | ```
30 | Warning : Be sure that no other app is running on port 3000, 9090 or 3306
31 | ```
32 |
33 | # Project development set up
34 |
35 | * This project use [liquibase](https://www.liquibase.org/) as a version control for database. When you will start backend, all tables and structures will be generated automatically.
36 | * You can disable Liquibase by setting ```spring.liquibase.enabled=false``` in ```application.properties```.
37 | * To try the project on localhost, check that nothing runs on ports 9090 (Spring Boot) and 3000 (React app)
38 | * You can edit ````spring.datasource```` in ```backend/src/main/resources/application.properties``` and ```username``` and ```password``` in ```backend/src/main/resources/liquibase.properties``` with your own MySQL login / password
39 | * Create a database named "fastlitemessage_dev" or you can also modify the name in the properties files mentioned just above.
40 |
41 | ## Start backend
42 | * Go inside backend folder then type ```mvn spring-boot:run``` to launch backend.
43 | * Or you can type ```mvn clean package``` to generate a JAR file and then start server with ```java -jar path/to/jar/file``` (Normally in inside backend/target/)
44 | ## Start frontend
45 | * Go inside frontend-web folder and then type ```npm run start```
46 |
47 | # Disclaimer
48 | * Please note there is no specific security over websockets.
49 | * Docker setup is not production ready
50 |
51 | # Project overview
52 |
53 | 
54 |
55 | 
56 |
57 | * Simple chat group application
58 | * Send images
59 | * Start video calls
60 | * Secure user account
61 | * Room discussion
62 | * Chat group administrators
63 | * Add / remove users from conversation
64 |
--------------------------------------------------------------------------------
/backend/core/src/main/java/com/mercure/core/service/UserService.java:
--------------------------------------------------------------------------------
1 | package com.mercure.core.service;
2 |
3 | import com.mercure.commons.dto.GroupMemberDTO;
4 | import com.mercure.commons.entity.GroupRoleKey;
5 | import com.mercure.commons.entity.GroupUser;
6 | import com.mercure.commons.entity.UserEntity;
7 | import com.mercure.core.repository.UserRepository;
8 | import jakarta.transaction.Transactional;
9 | import lombok.AllArgsConstructor;
10 | import lombok.Getter;
11 | import lombok.Setter;
12 | import org.springframework.stereotype.Service;
13 |
14 | import java.text.Normalizer;
15 | import java.util.*;
16 |
17 | @Service
18 | @Getter
19 | @Setter
20 | @AllArgsConstructor
21 | public class UserService {
22 |
23 | // private PasswordEncoder passwordEncoder;
24 |
25 | private UserRepository userRepository;
26 |
27 | private GroupUserJoinService groupUserJoinService;
28 |
29 | private static Map wsSessions = new HashMap<>();
30 |
31 | public List findAll() {
32 | return userRepository.findAll();
33 | }
34 |
35 | @Transactional
36 | public void save(UserEntity userEntity) {
37 | userRepository.save(userEntity);
38 | }
39 |
40 | public List fetchAllUsers(int[] ids) {
41 | List toSend = new ArrayList<>();
42 | List list = userRepository.getAllUsersNotAlreadyInConversation(ids);
43 | list.forEach(user -> toSend.add(new GroupMemberDTO(user.getId(), user.getFirstName(), user.getLastName(), false)));
44 | return toSend;
45 | }
46 |
47 | public UserEntity findByNameOrEmail(String str0, String str1) {
48 | return userRepository.getUserByFirstNameOrMail(str0, str1);
49 | }
50 |
51 | public boolean checkIfUserIsAdmin(int userId, int groupIdToCheck) {
52 | GroupRoleKey id = new GroupRoleKey(groupIdToCheck, userId);
53 | Optional optional = groupUserJoinService.findById(id);
54 | if (optional.isPresent()) {
55 | GroupUser groupUser = optional.get();
56 | return groupUser.getRole() == 1;
57 | }
58 | return false;
59 | }
60 |
61 | public String createShortUrl(String firstName, String lastName) {
62 | StringBuilder sb = new StringBuilder();
63 | sb.append(firstName);
64 | sb.append(".");
65 | sb.append(Normalizer.normalize(lastName.toLowerCase(), Normalizer.Form.NFD));
66 | boolean isShortUrlAvailable = true;
67 | int counter = 0;
68 | while (isShortUrlAvailable) {
69 | sb.append(counter);
70 | if (userRepository.countAllByShortUrl(sb.toString()) == 0) {
71 | isShortUrlAvailable = false;
72 | }
73 | counter++;
74 | }
75 | return sb.toString();
76 | }
77 |
78 | public String findUsernameById(int id) {
79 | return userRepository.getUsernameByUserId(id);
80 | }
81 |
82 | public String findFirstNameById(int id) {
83 | return userRepository.getFirstNameByUserId(id);
84 | }
85 |
86 | public UserEntity findById(int id) {
87 | return userRepository.findById(id).orElse(null);
88 | }
89 |
90 | public String passwordEncoder(String str) {
91 | // return passwordEncoder.encode(str);
92 | return str;
93 | }
94 |
95 | public boolean checkIfUserNameOrMailAlreadyUsed(String mail) {
96 | return userRepository.countAllByMail(mail) > 0;
97 | }
98 | }
99 |
--------------------------------------------------------------------------------