diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..bf82ff0 Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..ca5ab4b --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.7/apache-maven-3.8.7-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ecdd6c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM openjdk:11 +COPY ./target/ChatApplication-0.0.1-SNAPSHOT.jar /usr/src/chatapplication/ +WORKDIR /usr/src/chatapplication +EXPOSE 8080 +CMD ["java", "-jar", "ChatApplication-0.0.1-SNAPSHOT.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f47c60e --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Week 10-3, Practice + +## Section 1: + +### Task 1: Create Chat Application Using Websocket: + +Overview: +- Oracle Database: store user information +- Websocket: (See here) +- RabbitMQ: STOMP broker, keep track of subscriptions and broadcasts messages to subscribed users. (Alternation for default in-memory STOMP broker of Spring) (See here) +- Redis: in-memory database, use for store WebSocket Session, Chat Room information and messages. (See here) +- Apache Zookeeper +- Apache Kafka: (See here) + +#### Result +- Chat Application with multiple room (multiple WebSocket channel): + + ![login.png](images%2Flogin.png) + ![img.png](images%2Fimg.png) + ![img_1.png](images%2Fimg_1.png) +## Section 2: +### Task 1: Add Kafka to project +[ProducerKafkaConfiguration.java](src%2Fmain%2Fjava%2Fcom%2Fexample%2Fchatapplication%2Fconfiguration%2Fkafka%2FProducerKafkaConfiguration.java) +[ConsumerKafkaConfiguration.java](src%2Fmain%2Fjava%2Fcom%2Fexample%2Fchatapplication%2Fconfiguration%2Fkafka%2FConsumerKafkaConfiguration.java) + +- Dynamic create/delete topic, also change Consumer topic at runtime: + +```agsl +@Service +@Slf4j +public class KafkaService { + @Autowired + private AdminClient adminClient; + + @Autowired + ConcurrentKafkaListenerContainerFactory listenerContainerFactory; + + @Autowired + ConsumerFactory consumerFactory; + + ConcurrentMessageListenerContainer listenerContainer; + public void changeTopic(String topic) throws InterruptedException { + log.info("Changing topic to: {}", topic); + if(listenerContainer != null) { + listenerContainer.stop(); + Thread.sleep(2000); + listenerContainer.destroy(); + Thread.sleep(2000); + } + ContainerProperties containerProperties = new ContainerProperties(topic); + containerProperties.setGroupId(RandomStringUtils.randomAlphanumeric(3)); + containerProperties.setMessageListener((MessageListener) message -> { + System.out.println("Kafka listener, topic: " + message.topic().toString() + ", message content: " + message.value().getContent()); + }); + listenerContainer = new ConcurrentMessageListenerContainer<>(consumerFactory, containerProperties); + listenerContainer.start(); + } + + public void createTopic(String topic) { + adminClient.createTopics(List.of(TopicBuilder.name(topic).build())); + } + + public void deleteTopic(String topic) { + adminClient.deleteTopics(List.of(topic)); + } +} +``` + +### Task 2: Run project using Docker +[docker-compose.yml](docker%2Fdocker-compose.yml) + +![img_5.png](images%2Fimg_5.png) + + +### Task 3: Dynamically create/delete Kafka topic +```agsl +public void createTopic(String topic) { + adminClient.createTopics(List.of(TopicBuilder.name(topic).build())); + } + + public void deleteTopic(String topic) { + adminClient.deleteTopics(List.of(topic)); + } +``` +![img_6.png](images%2Fimg_6.png) \ No newline at end of file diff --git a/data/new 1.txt b/data/new 1.txt new file mode 100644 index 0000000..6096be7 --- /dev/null +++ b/data/new 1.txt @@ -0,0 +1 @@ +user1:999999999 \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..0d4acd5 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,57 @@ +version: '3' + +networks: + chat-app: + driver: bridge + +services: + chat-app-zookeeper: + image: 'bitnami/zookeeper:latest' + ports: + - '2181:2181' + environment: + - ALLOW_ANONYMOUS_LOGIN=yes + networks: + - chat-app + chat-app-kafka: + image: 'bitnami/kafka:latest' + ports: + - '9092:9092' + environment: + - KAFKA_CFG_ZOOKEEPER_CONNECT=chat-app-zookeeper:2181 + - ALLOW_ANONYMOUS_LOGIN=yes + - ALLOW_PLAINTEXT_LISTENER=yes + - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT + - KAFKA_CFG_LISTENERS=CLIENT://:9092,EXTERNAL://localhost:9093 + - KAFKA_CFG_ADVERTISED_LISTENERS=CLIENT://host.docker.internal:9092,EXTERNAL://localhost:9093 + - KAFKA_CFG_INTER_BROKER_LISTENER_NAME=CLIENT + depends_on: + - chat-app-zookeeper + networks: + - chat-app + chat-app-rabbitmq-stomp: + image: 'pcloud/rabbitmq-stomp:latest' + ports: + - '5672:5672' + - '15672:15672' + - '61613:61613' + networks: + - chat-app + chatApplication: + image: chatapplication:0.0.1 + ports: + - '8080:8080' + environment: + - SPRING_PROFILES_ACTIVE=docker + depends_on: + - chat-app-kafka + - chat-app-rabbitmq-stomp + networks: + - chat-app + chat-app-redis: + image: redis/redis-stack:latest + ports: + - '6381:6379' + - '8003:8001' + networks: + - chat-app \ No newline at end of file diff --git a/images/img.png b/images/img.png new file mode 100644 index 0000000..1b1c4e3 Binary files /dev/null and b/images/img.png differ diff --git a/images/img_1.png b/images/img_1.png new file mode 100644 index 0000000..85953af Binary files /dev/null and b/images/img_1.png differ diff --git a/images/img_2.png b/images/img_2.png new file mode 100644 index 0000000..e81a972 Binary files /dev/null and b/images/img_2.png differ diff --git a/images/img_3.png b/images/img_3.png new file mode 100644 index 0000000..c33d3de Binary files /dev/null and b/images/img_3.png differ diff --git a/images/img_4.png b/images/img_4.png new file mode 100644 index 0000000..295342d Binary files /dev/null and b/images/img_4.png differ diff --git a/images/img_5.png b/images/img_5.png new file mode 100644 index 0000000..af3b19b Binary files /dev/null and b/images/img_5.png differ diff --git a/images/img_6.png b/images/img_6.png new file mode 100644 index 0000000..a47b4f9 Binary files /dev/null and b/images/img_6.png differ diff --git a/images/login.png b/images/login.png new file mode 100644 index 0000000..671915e Binary files /dev/null and b/images/login.png differ diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..8a8fb22 --- /dev/null +++ b/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..1d8ab01 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..033eb88 --- /dev/null +++ b/pom.xml @@ -0,0 +1,154 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.8 + + + com.example + ChatApplication + 0.0.1-SNAPSHOT + ChatApplication + ChatApplication + + 11 + + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.oracle.database.jdbc + ojdbc8 + + + org.springframework.session + spring-session-data-redis + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.thymeleaf.extras + thymeleaf-extras-springsecurity5 + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-devtools + + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-reactor-netty + + + org.springframework.kafka + spring-kafka + + + org.springframework.boot + spring-boot-starter-security + + + org.webjars + jquery + 3.6.3 + + + org.webjars + bootstrap + 5.2.3 + + + org.webjars + webjars-locator + 0.46 + + + org.webjars + font-awesome + 6.2.0 + + + org.webjars + sockjs-client + 1.5.1 + + + org.webjars + stomp-websocket + 2.3.4 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.14.2 + + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.kafka + spring-kafka-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + ttf + woff + woff2 + + + + + + + diff --git a/src/main/java/com/example/chatapplication/ChatApplication.java b/src/main/java/com/example/chatapplication/ChatApplication.java new file mode 100644 index 0000000..c99a7de --- /dev/null +++ b/src/main/java/com/example/chatapplication/ChatApplication.java @@ -0,0 +1,35 @@ +package com.example.chatapplication; + +import com.example.chatapplication.chatroom.model.Message; +import com.example.chatapplication.chatroom.model.Room; +import com.example.chatapplication.chatroom.repository.RoomRepository; +import com.example.chatapplication.chatroom.service.RoomService; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.kafka.core.KafkaTemplate; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@SpringBootApplication +public class ChatApplication { + + public static void main(String[] args) { + SpringApplication.run(ChatApplication.class, args); + } + + @Bean + CommandLineRunner commandLineRunner(RoomRepository roomRepository) { + return args -> { + Room room = new Room(); + room.setId("123"); + room.setName("Rao vặt"); + room.setDescription("Rao vặt đủ thứ"); + room.setConnectedUsers(new ArrayList<>()); + roomRepository.save(room); + }; + } +} diff --git a/src/main/java/com/example/chatapplication/authentication/controller/AuthenticationController.java b/src/main/java/com/example/chatapplication/authentication/controller/AuthenticationController.java new file mode 100644 index 0000000..568482f --- /dev/null +++ b/src/main/java/com/example/chatapplication/authentication/controller/AuthenticationController.java @@ -0,0 +1,62 @@ +package com.example.chatapplication.authentication.controller; + +import com.example.chatapplication.authentication.model.User; +import com.example.chatapplication.authentication.service.UserService; +import com.example.chatapplication.authentication.validator.NewUserValidator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.InitBinder; +import org.springframework.web.bind.annotation.PostMapping; + +import javax.validation.Valid; + +@Controller +public class AuthenticationController { + @Autowired + private UserService userService; + @Autowired + private NewUserValidator validator; + + @InitBinder + protected void initBinder(WebDataBinder webDataBinder) { + webDataBinder.addValidators(validator); + } + + @GetMapping("/login") + public String loginPage() { + SecurityContext context = SecurityContextHolder.getContext(); + Authentication authentication = context.getAuthentication(); + if(authentication == null || authentication instanceof AnonymousAuthenticationToken) { + return "login"; + } + return "redirect:/room/123"; + } + + @GetMapping("/") + public String homePage() { + return "redirect:/room/123"; + } + + @GetMapping("/new-account") + public String newAccount(ModelMap model) { + model.put("user", new User()); + return "new-account"; + } + + @PostMapping("/new-account") + public String saveNewAccount(@Valid User user, BindingResult bindingResult) { + if(bindingResult.hasErrors()) { + return "new-account"; + } + userService.createUser(user); + return "redirect:/"; + } +} diff --git a/src/main/java/com/example/chatapplication/authentication/model/Role.java b/src/main/java/com/example/chatapplication/authentication/model/Role.java new file mode 100644 index 0000000..e1862bf --- /dev/null +++ b/src/main/java/com/example/chatapplication/authentication/model/Role.java @@ -0,0 +1,37 @@ +package com.example.chatapplication.authentication.model; + +import lombok.*; + +import javax.persistence.*; +import java.util.Objects; + +@Entity +@Table(name = "roles") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Role { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + private String name; + + public Role(Integer id) { + this.id = id; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Role role = (Role) o; + return id.equals(role.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/src/main/java/com/example/chatapplication/authentication/model/User.java b/src/main/java/com/example/chatapplication/authentication/model/User.java new file mode 100644 index 0000000..71180f4 --- /dev/null +++ b/src/main/java/com/example/chatapplication/authentication/model/User.java @@ -0,0 +1,50 @@ +package com.example.chatapplication.authentication.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.*; +import javax.validation.constraints.Email; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Size; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "user_db") +public class User { + + @Id + @NotEmpty(message = "Username is mandatory") + @Size(min = 5, max = 15, message = "Min 5, max 15") + private String username; + + @NotEmpty(message = "Password is mandatory") + @Size(min = 8, message = "At least 8 characters") + private String password; + + @NotEmpty(message = "Name is mandatory") + private String name; + + @Email(message = "Invalid email") + @NotEmpty(message = "Email is mandatory") + private String email; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable(name = "user_role", + joinColumns = @JoinColumn(name = "username"), + inverseJoinColumns = @JoinColumn(name = "role_id")) + private Set roles = new HashSet<>(); + private boolean isOnline; + + public void addRoles(Collection roles) { + this.roles.addAll(roles); + } +} diff --git a/src/main/java/com/example/chatapplication/authentication/repository/RoleRepository.java b/src/main/java/com/example/chatapplication/authentication/repository/RoleRepository.java new file mode 100644 index 0000000..4e601aa --- /dev/null +++ b/src/main/java/com/example/chatapplication/authentication/repository/RoleRepository.java @@ -0,0 +1,9 @@ +package com.example.chatapplication.authentication.repository; + +import com.example.chatapplication.authentication.model.Role; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RoleRepository extends JpaRepository { + + Role findByName(String name); +} diff --git a/src/main/java/com/example/chatapplication/authentication/repository/UserRepository.java b/src/main/java/com/example/chatapplication/authentication/repository/UserRepository.java new file mode 100644 index 0000000..2a99579 --- /dev/null +++ b/src/main/java/com/example/chatapplication/authentication/repository/UserRepository.java @@ -0,0 +1,8 @@ +package com.example.chatapplication.authentication.repository; + +import com.example.chatapplication.authentication.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + +} diff --git a/src/main/java/com/example/chatapplication/authentication/service/UserDetailsServiceImpl.java b/src/main/java/com/example/chatapplication/authentication/service/UserDetailsServiceImpl.java new file mode 100644 index 0000000..90a8523 --- /dev/null +++ b/src/main/java/com/example/chatapplication/authentication/service/UserDetailsServiceImpl.java @@ -0,0 +1,32 @@ +package com.example.chatapplication.authentication.service; + +import com.example.chatapplication.authentication.model.User; +import com.example.chatapplication.authentication.repository.RoleRepository; +import com.example.chatapplication.authentication.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + @Autowired private UserRepository userRepository; + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findById(username).orElseThrow( + () -> new BadCredentialsException("Username not found") + ); + List authorities = user.getRoles().stream() + .map(role -> new SimpleGrantedAuthority(role.getName())) + .collect(Collectors.toList()); + return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities); + } +} diff --git a/src/main/java/com/example/chatapplication/authentication/service/UserService.java b/src/main/java/com/example/chatapplication/authentication/service/UserService.java new file mode 100644 index 0000000..3043f14 --- /dev/null +++ b/src/main/java/com/example/chatapplication/authentication/service/UserService.java @@ -0,0 +1,10 @@ +package com.example.chatapplication.authentication.service; + +import com.example.chatapplication.authentication.model.User; + +import javax.transaction.Transactional; + +public interface UserService { + @Transactional + User createUser(User user); +} diff --git a/src/main/java/com/example/chatapplication/authentication/service/UserServiceImpl.java b/src/main/java/com/example/chatapplication/authentication/service/UserServiceImpl.java new file mode 100644 index 0000000..63297dc --- /dev/null +++ b/src/main/java/com/example/chatapplication/authentication/service/UserServiceImpl.java @@ -0,0 +1,33 @@ +package com.example.chatapplication.authentication.service; + +import com.example.chatapplication.authentication.model.Role; +import com.example.chatapplication.authentication.model.User; +import com.example.chatapplication.authentication.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; +import java.util.Collection; +import java.util.Set; + +@Service +@Slf4j +public class UserServiceImpl implements UserService { + @Autowired private UserRepository userRepository; + @Autowired private PasswordEncoder passwordEncoder; + private static final Logger LOGGER = LoggerFactory.getLogger(UserServiceImpl.class); + + @Transactional + @Override + public User createUser(User user) { + LOGGER.info("Creating new user with username: {}", user.getUsername()); + user.setOnline(false); + user.addRoles(Set.of(new Role(4))); + user.setPassword(passwordEncoder.encode(user.getPassword())); + return userRepository.save(user); + } +} diff --git a/src/main/java/com/example/chatapplication/authentication/validator/NewUserValidator.java b/src/main/java/com/example/chatapplication/authentication/validator/NewUserValidator.java new file mode 100644 index 0000000..b669bd4 --- /dev/null +++ b/src/main/java/com/example/chatapplication/authentication/validator/NewUserValidator.java @@ -0,0 +1,28 @@ +package com.example.chatapplication.authentication.validator; + +import com.example.chatapplication.authentication.model.User; +import com.example.chatapplication.authentication.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + + +@Component +public class NewUserValidator implements Validator { + @Autowired + private UserRepository userRepository; + + @Override + public boolean supports(Class clazz) { + return User.class.isAssignableFrom(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + User user = (User) target; + if(userRepository.existsById(user.getUsername())) { + errors.rejectValue("username", "new.account.username.already.exists"); + } + } +} diff --git a/src/main/java/com/example/chatapplication/chatroom/controller/RoomController.java b/src/main/java/com/example/chatapplication/chatroom/controller/RoomController.java new file mode 100644 index 0000000..6036401 --- /dev/null +++ b/src/main/java/com/example/chatapplication/chatroom/controller/RoomController.java @@ -0,0 +1,82 @@ +package com.example.chatapplication.chatroom.controller; + +import com.example.chatapplication.chatroom.model.Message; +import com.example.chatapplication.chatroom.model.Room; +import com.example.chatapplication.chatroom.model.RoomUser; +import com.example.chatapplication.chatroom.service.MessageService; +import com.example.chatapplication.chatroom.service.RoomService; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.annotation.SubscribeMapping; +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Controller +@Slf4j +public class RoomController { + private final Logger LOG = LoggerFactory.getLogger(RoomController.class); + @Autowired + private RoomService roomService; + @Autowired + private MessageService messageService; + + + @Secured("ROLE_ADMIN") + @PostMapping("/room") + @ResponseBody + @ResponseStatus(HttpStatus.CREATED) + public Room createRoom(Room room) { + return roomService.save(room); + } + + @GetMapping("/room/{roomId}") + public String join(@PathVariable(name = "roomId", required = false) String roomId, + Authentication authentication, + ModelMap model) { + if(roomId == null) roomId = "123"; + List roomList = roomService.findAll(); + model.put("roomList", roomList); + Room room = roomService.findById(roomId); + model.put("room", room); + return "chat"; + } + + @SubscribeMapping("/connected.users") + public List listChatRoomConnectedUsersOnSubscribe(SimpMessageHeaderAccessor headerAccessor) { + String roomId = headerAccessor.getSessionAttributes().get("roomId").toString(); + List roomUserList = roomService.findById(roomId).getConnectedUsers(); + + LOG.info("Room {}, list room user size: {}", roomId, roomUserList.size()); + return roomUserList; + } + + @SubscribeMapping("/old.messages") + public List listOldMessageFromUserOnSubscribe(Authentication authentication, + SimpMessageHeaderAccessor headerAccessor) { + String roomId = headerAccessor.getSessionAttributes().get("roomId").toString(); + return messageService.findAllMessageFor(roomId); + } + + @MessageMapping("/send.message") + public void sendMessage(@Payload Message message, + Authentication authentication, + SimpMessageHeaderAccessor headerAccessor) { + String roomId = headerAccessor.getSessionAttributes().get("roomId").toString(); + String fromUser = authentication.getName(); + message.setRoomId(roomId); + message.setFromUser(fromUser); + + roomService.sendPublicMessage(message); + } +} diff --git a/src/main/java/com/example/chatapplication/chatroom/listener/WebsocketEvents.java b/src/main/java/com/example/chatapplication/chatroom/listener/WebsocketEvents.java new file mode 100644 index 0000000..174ed0c --- /dev/null +++ b/src/main/java/com/example/chatapplication/chatroom/listener/WebsocketEvents.java @@ -0,0 +1,48 @@ +package com.example.chatapplication.chatroom.listener; + +import com.example.chatapplication.chatroom.model.RoomUser; +import com.example.chatapplication.chatroom.service.RoomService; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionConnectEvent; +import org.springframework.web.socket.messaging.SessionConnectedEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +import java.time.LocalDateTime; + +@Component +@Slf4j +public class WebsocketEvents { + private final Logger LOG = LoggerFactory.getLogger(WebsocketEvents.class); + @Autowired + SimpMessagingTemplate template; + @Autowired + private RoomService roomService; + + @EventListener + public void handleSessionConnect(SessionConnectEvent event) { + SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.wrap(event.getMessage()); + String roomId = headerAccessor.getNativeHeader("roomId").get(0); + LOG.info("Handling session connect event, room id: {}", roomId); + headerAccessor.getSessionAttributes().put("roomId", roomId); + RoomUser roomUser = new RoomUser(event.getUser().getName(), LocalDateTime.now()); + + roomService.join(roomUser, roomService.findById(roomId)); + } + + @EventListener + public void handleSessionDisconnect(SessionDisconnectEvent event) { + SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.wrap(event.getMessage()); + String roomId = headerAccessor.getSessionAttributes().get("roomId").toString(); + LOG.info("Handling user disconnect event, room id: {}", roomId); + RoomUser leavingUser = new RoomUser(event.getUser().getName()); + + roomService.leave(leavingUser, roomService.findById(roomId)); + } +} diff --git a/src/main/java/com/example/chatapplication/chatroom/model/Message.java b/src/main/java/com/example/chatapplication/chatroom/model/Message.java new file mode 100644 index 0000000..8b0c759 --- /dev/null +++ b/src/main/java/com/example/chatapplication/chatroom/model/Message.java @@ -0,0 +1,23 @@ +package com.example.chatapplication.chatroom.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.redis.core.RedisHash; + +import java.time.LocalDateTime; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class Message { + + private String username; + private String roomId; + private LocalDateTime dateTime; + private String fromUser; + private String toUser; + private String content; +} diff --git a/src/main/java/com/example/chatapplication/chatroom/model/MessageBuilder.java b/src/main/java/com/example/chatapplication/chatroom/model/MessageBuilder.java new file mode 100644 index 0000000..48a7f11 --- /dev/null +++ b/src/main/java/com/example/chatapplication/chatroom/model/MessageBuilder.java @@ -0,0 +1,72 @@ +package com.example.chatapplication.chatroom.model; + +import com.example.chatapplication.utils.SystemUsers; + +import java.time.LocalDateTime; + +public class MessageBuilder { + + private Message message; + private MessageChatRoom messageChatRoom; + private MessageType messageType; + private MessageToUser messageToUser; + private MessageFromUser messageFromUser; + private MessageText messageText; + + public MessageChatRoom newMessage() { + message = new Message(); + messageChatRoom = new MessageChatRoom(); + return messageChatRoom; + } + + public class MessageChatRoom { + public MessageType toRoomId(String roomId) { + message.setRoomId(roomId); + messageType = new MessageType(); + return messageType; + } + } + + public class MessageType { + public MessageText systemMessage() { + message.setFromUser(SystemUsers.ADMIN.getUsername()); + messageText = new MessageText(); + return messageText; + } + + public MessageFromUser publicMessage() { + message.setToUser(null); + messageFromUser = new MessageFromUser(); + return messageFromUser; + } + + public MessageToUser privateMessage() { + messageToUser = new MessageToUser(); + return messageToUser; + } + } + + public class MessageToUser { + public MessageFromUser toUser(String username) { + message.setToUser(username); + messageFromUser = new MessageFromUser(); + return messageFromUser; + } + } + + public class MessageFromUser { + public MessageText fromUser(String username) { + message.setFromUser(username); + messageText = new MessageText(); + return messageText; + } + } + + public class MessageText { + public Message withContent(String content) { + message.setContent(content); + message.setDateTime(LocalDateTime.now()); + return message; + } + } +} diff --git a/src/main/java/com/example/chatapplication/chatroom/model/Room.java b/src/main/java/com/example/chatapplication/chatroom/model/Room.java new file mode 100644 index 0000000..d241289 --- /dev/null +++ b/src/main/java/com/example/chatapplication/chatroom/model/Room.java @@ -0,0 +1,37 @@ +package com.example.chatapplication.chatroom.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +import java.util.ArrayList; +import java.util.List; + +@RedisHash("rooms") +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class Room { + + @Id + private String id; + private String name; + private String description; + private List connectedUsers = new ArrayList<>(); + + public int getNumberOfConnectedUsers() { + return this.connectedUsers.size(); + } + + public void addUser(RoomUser roomUser) { + connectedUsers.add(roomUser); + } + + public void removeUser(RoomUser roomUser) { + connectedUsers.removeAll(List.of(roomUser)); + } +} diff --git a/src/main/java/com/example/chatapplication/chatroom/model/RoomUser.java b/src/main/java/com/example/chatapplication/chatroom/model/RoomUser.java new file mode 100644 index 0000000..31e1506 --- /dev/null +++ b/src/main/java/com/example/chatapplication/chatroom/model/RoomUser.java @@ -0,0 +1,40 @@ +package com.example.chatapplication.chatroom.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class RoomUser implements Comparable{ + private String username; + private LocalDateTime joinedAt; + + public RoomUser(String username) { + this.username = username; + } + + @Override + public int compareTo(RoomUser o) { + return this.username.compareTo(o.getUsername()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RoomUser roomUser = (RoomUser) o; + return Objects.equals(username, roomUser.username); + } + + @Override + public int hashCode() { + return Objects.hash(username); + } +} diff --git a/src/main/java/com/example/chatapplication/chatroom/repository/RoomRepository.java b/src/main/java/com/example/chatapplication/chatroom/repository/RoomRepository.java new file mode 100644 index 0000000..983be6a --- /dev/null +++ b/src/main/java/com/example/chatapplication/chatroom/repository/RoomRepository.java @@ -0,0 +1,8 @@ +package com.example.chatapplication.chatroom.repository; + +import com.example.chatapplication.chatroom.model.Room; +import org.springframework.data.repository.CrudRepository; + +public interface RoomRepository extends CrudRepository { + +} diff --git a/src/main/java/com/example/chatapplication/chatroom/service/MessageService.java b/src/main/java/com/example/chatapplication/chatroom/service/MessageService.java new file mode 100644 index 0000000..159c480 --- /dev/null +++ b/src/main/java/com/example/chatapplication/chatroom/service/MessageService.java @@ -0,0 +1,11 @@ +package com.example.chatapplication.chatroom.service; + +import com.example.chatapplication.chatroom.model.Message; + +import java.util.List; + +public interface MessageService { + List findAllMessageFor(String username); + + void appendMessageToConversations(Message message); +} diff --git a/src/main/java/com/example/chatapplication/chatroom/service/RoomService.java b/src/main/java/com/example/chatapplication/chatroom/service/RoomService.java new file mode 100644 index 0000000..294f49f --- /dev/null +++ b/src/main/java/com/example/chatapplication/chatroom/service/RoomService.java @@ -0,0 +1,23 @@ +package com.example.chatapplication.chatroom.service; + +import com.example.chatapplication.chatroom.model.Message; +import com.example.chatapplication.chatroom.model.Room; +import com.example.chatapplication.chatroom.model.RoomUser; + +import java.util.List; + +public interface RoomService { + List findAll(); + + Room save(Room room); + + Room findById(String id); + + Room join(RoomUser joiningUser, Room room); + + Room leave(RoomUser leavingUser, Room room); + + void sendPublicMessage(Message message); + + void updateConnectedUsersViaWebsocket(Room room); +} diff --git a/src/main/java/com/example/chatapplication/chatroom/service/implement/RedisMessageServiceImpl.java b/src/main/java/com/example/chatapplication/chatroom/service/implement/RedisMessageServiceImpl.java new file mode 100644 index 0000000..eb21c13 --- /dev/null +++ b/src/main/java/com/example/chatapplication/chatroom/service/implement/RedisMessageServiceImpl.java @@ -0,0 +1,34 @@ +package com.example.chatapplication.chatroom.service.implement; + +import com.example.chatapplication.chatroom.model.Message; +import com.example.chatapplication.chatroom.service.MessageService; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@Slf4j +public class RedisMessageServiceImpl implements MessageService { + private final Logger LOG = LoggerFactory.getLogger(RedisRoomServiceImpl.class); + @Autowired + private RedisTemplate redisTemplate; + + @Override + public List findAllMessageFor(String roomId) { + List messageList = redisTemplate.opsForList().range(roomId, 0, -1); + LOG.info("Getting message list for room: {}, size: {}", roomId, messageList.size()); + return messageList; + } + + @Override + public void appendMessageToConversations(Message message) { + LOG.info("Appending message to conversations, room id: {}" + message.getRoomId()); + redisTemplate.opsForList().rightPush(message.getRoomId(), message); + } +} diff --git a/src/main/java/com/example/chatapplication/chatroom/service/implement/RedisRoomServiceImpl.java b/src/main/java/com/example/chatapplication/chatroom/service/implement/RedisRoomServiceImpl.java new file mode 100644 index 0000000..5d11234 --- /dev/null +++ b/src/main/java/com/example/chatapplication/chatroom/service/implement/RedisRoomServiceImpl.java @@ -0,0 +1,112 @@ +package com.example.chatapplication.chatroom.service.implement; + +import com.example.chatapplication.chatroom.model.Message; +import com.example.chatapplication.chatroom.model.Room; +import com.example.chatapplication.chatroom.model.RoomUser; +import com.example.chatapplication.chatroom.repository.RoomRepository; +import com.example.chatapplication.chatroom.service.MessageService; +import com.example.chatapplication.chatroom.service.RoomService; +import com.example.chatapplication.configuration.kafka.KafkaTopics; +import com.example.chatapplication.utils.Destinations; +import com.example.chatapplication.utils.SystemMessages; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.NoSuchElementException; + +@Service +@Slf4j +public class RedisRoomServiceImpl implements RoomService { + private final Logger LOG = LoggerFactory.getLogger(RedisRoomServiceImpl.class); + @Autowired + private RoomRepository roomRepository; + @Autowired + private MessageService messageService; + @Autowired + private SimpMessagingTemplate websocketTemplate; + @Autowired + private KafkaTemplate kafkaTemplate; + + @Override + public List findAll() { + LOG.info("Getting all room"); + return (List) roomRepository.findAll(); + } + + @Override + public Room save(Room room) { + LOG.info("Create new room, name: {}" + room.getName()); + room.setId(RandomStringUtils.randomAlphanumeric(5)); + return roomRepository.save(room); + } + + @Override + public Room findById(String id) { + LOG.info("Finding room id: {}", id); + return roomRepository.findById(id).orElseThrow( + () -> new NoSuchElementException("Room not found") + ); + } + + @Override + public Room join(RoomUser joiningUser, Room room) { + LOG.info("User -{}- is joining room id -{}-", joiningUser.getUsername(), room.getId()); + room.addUser(joiningUser); + roomRepository.save(room); + + Message message = SystemMessages.welcome(room.getId(), joiningUser.getUsername()); + //send message to room + sendPublicMessage(message); + LOG.info("Message content: {}", message.getContent()); + //update connected user via websocket + updateConnectedUsersViaWebsocket(room); + return room; + } + + @Override + public Room leave(RoomUser leavingUser, Room room) { + //send bye bye message + sendPublicMessage(SystemMessages.goodbye(room.getId(), leavingUser.getUsername())); + + room.removeUser(leavingUser); + roomRepository.save(room); + + //update connected user via websocket + updateConnectedUsersViaWebsocket(room); + return room; + } + + @Override + public void sendPublicMessage(Message message) { + message.setDateTime(LocalDateTime.now()); + String destination = Destinations.Room.publicMessages(message.getRoomId()); + LOG.info("Sending public message to: {}", destination); + websocketTemplate.convertAndSend( + destination, + message); + if(!message.getFromUser().equals("admin")) { + messageService.appendMessageToConversations(message); + kafkaTemplate.send(KafkaTopics.TOPIC1, message); + kafkaTemplate.send(KafkaTopics.TOPIC2, message); + } + + } + + @Override + public void updateConnectedUsersViaWebsocket(Room room) { + String destination = Destinations.Room.connectedUsers(room.getId()); + LOG.info("Updating connected user to: {}", destination); + websocketTemplate.convertAndSend( + destination, + room.getConnectedUsers()); + } +} diff --git a/src/main/java/com/example/chatapplication/configuration/JsonConfiguration.java b/src/main/java/com/example/chatapplication/configuration/JsonConfiguration.java new file mode 100644 index 0000000..e056f12 --- /dev/null +++ b/src/main/java/com/example/chatapplication/configuration/JsonConfiguration.java @@ -0,0 +1,15 @@ +package com.example.chatapplication.configuration; + +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.datatype.jsr310.JSR310Module; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; + +@Configuration +public class JsonConfiguration { + @Bean + public Module jsr310Module() { + return new JSR310Module(); + } +} diff --git a/src/main/java/com/example/chatapplication/configuration/RedisConfiguration.java b/src/main/java/com/example/chatapplication/configuration/RedisConfiguration.java new file mode 100644 index 0000000..9dc8e86 --- /dev/null +++ b/src/main/java/com/example/chatapplication/configuration/RedisConfiguration.java @@ -0,0 +1,47 @@ +package com.example.chatapplication.configuration; + +import com.example.chatapplication.chatroom.model.Message; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.util.List; + +@Configuration +@EnableRedisRepositories +@EnableCaching +public class RedisConfiguration { + @Value("${spring.redis.host}") + private String redisHost; + @Value("${spring.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(new RedisStandaloneConfiguration(redisHost, redisPort)); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + Jackson2JsonRedisSerializer redisSerializer = new Jackson2JsonRedisSerializer<>(Message.class); + redisSerializer.setObjectMapper(mapper); + redisTemplate.setValueSerializer(redisSerializer); + return redisTemplate; + } +} diff --git a/src/main/java/com/example/chatapplication/configuration/WebSecurityConfiguration.java b/src/main/java/com/example/chatapplication/configuration/WebSecurityConfiguration.java new file mode 100644 index 0000000..9065a49 --- /dev/null +++ b/src/main/java/com/example/chatapplication/configuration/WebSecurityConfiguration.java @@ -0,0 +1,38 @@ +package com.example.chatapplication.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@EnableWebSecurity +@Configuration +public class WebSecurityConfiguration { + + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf().disable(); + http.formLogin() + .loginPage("/login") + .usernameParameter("username") + .defaultSuccessUrl("/") + .and() + .logout() + .logoutSuccessUrl("/"); + + http.authorizeRequests() + .mvcMatchers("/login", "/new-account", "/ws/*").permitAll() + .mvcMatchers("/webjars/**", "/js/**", "/css/*", "/images/**").permitAll() + .anyRequest().authenticated(); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/example/chatapplication/configuration/WebSocketConfiguration.java b/src/main/java/com/example/chatapplication/configuration/WebSocketConfiguration.java new file mode 100644 index 0000000..b4f2931 --- /dev/null +++ b/src/main/java/com/example/chatapplication/configuration/WebSocketConfiguration.java @@ -0,0 +1,42 @@ +package com.example.chatapplication.configuration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.session.web.socket.config.annotation.AbstractSessionWebSocketMessageBrokerConfigurer; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; + +@Configuration +@EnableScheduling +@EnableWebSocketMessageBroker +public class WebSocketConfiguration extends AbstractSessionWebSocketMessageBrokerConfigurer { + + @Value("${chat.app.relay.host}") + private String relayHost; + @Value("${chat.app.relay.port}") + private int relayPort; + @Value("${chat.app.relay.username}") + private String username; + @Value("${chat.app.relay.password}") + private String password; + + @Override + protected void configureStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes("/room"); + registry.enableStompBrokerRelay("/topic/", "/queue/") + .setRelayHost(relayHost) + .setRelayPort(relayPort) + .setSystemLogin(username) + .setSystemPasscode(password) + .setClientLogin(username) + .setClientPasscode(password); + } +} diff --git a/src/main/java/com/example/chatapplication/configuration/kafka/ConsumerKafkaConfiguration.java b/src/main/java/com/example/chatapplication/configuration/kafka/ConsumerKafkaConfiguration.java new file mode 100644 index 0000000..499f78d --- /dev/null +++ b/src/main/java/com/example/chatapplication/configuration/kafka/ConsumerKafkaConfiguration.java @@ -0,0 +1,51 @@ +package com.example.chatapplication.configuration.kafka; + +import com.example.chatapplication.chatroom.model.Message; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.listener.ConcurrentMessageListenerContainer; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class ConsumerKafkaConfiguration { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServer; + + public Map consumerConfig() { + Map configurations = new HashMap<>(); + configurations.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServer); + configurations.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + configurations.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + return configurations; + } + + @Bean + public ConsumerFactory consumerFactory() { + DefaultKafkaConsumerFactory consumerFactory = new DefaultKafkaConsumerFactory<>(consumerConfig()); + JsonDeserializer jsonDeserializer = new JsonDeserializer<>(); + jsonDeserializer.addTrustedPackages("com.example.chatapplication.chatroom.model"); + consumerFactory.setValueDeserializer(jsonDeserializer); + return consumerFactory; + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory( + ConsumerFactory consumerFactory + ) { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory); + return factory; + } +} diff --git a/src/main/java/com/example/chatapplication/configuration/kafka/KafkaController.java b/src/main/java/com/example/chatapplication/configuration/kafka/KafkaController.java new file mode 100644 index 0000000..6d0f11a --- /dev/null +++ b/src/main/java/com/example/chatapplication/configuration/kafka/KafkaController.java @@ -0,0 +1,45 @@ +package com.example.chatapplication.configuration.kafka; + +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.KafkaAdminClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.kafka.config.TopicBuilder; +import org.springframework.kafka.core.KafkaAdmin; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +public class KafkaController { + @Autowired + private KafkaService kafkaService; + + @Secured("ROLE_ADMIN") + @GetMapping("/kafka") + public ResponseEntity changeKafkaTopic(@RequestParam String topic) { + try { + kafkaService.changeTopic(topic); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return ResponseEntity.ok("Change listener to topic: " + topic); + } + + @Secured("ROLE_ADMIN") + @GetMapping("/kafka/create-topic") + public ResponseEntity createTopic(@RequestParam String topic) { + kafkaService.createTopic(topic); + return ResponseEntity.ok("Successfully create topic: " + topic); + } + + @Secured("ROLE_ADMIN") + @GetMapping("/kafka/delete-topic") + public ResponseEntity deleteTopic(@RequestParam String topic) { + kafkaService.deleteTopic(topic); + return ResponseEntity.ok("Successfully delete topic: " + topic); + } +} diff --git a/src/main/java/com/example/chatapplication/configuration/kafka/KafkaService.java b/src/main/java/com/example/chatapplication/configuration/kafka/KafkaService.java new file mode 100644 index 0000000..e4306c9 --- /dev/null +++ b/src/main/java/com/example/chatapplication/configuration/kafka/KafkaService.java @@ -0,0 +1,53 @@ +package com.example.chatapplication.configuration.kafka; + +import com.example.chatapplication.chatroom.model.Message; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.kafka.clients.admin.AdminClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.config.KafkaListenerEndpointRegistry; +import org.springframework.kafka.config.TopicBuilder; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.listener.ConcurrentMessageListenerContainer; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.listener.MessageListener; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@Slf4j +public class KafkaService { + @Autowired + private AdminClient adminClient; + @Autowired + ConcurrentKafkaListenerContainerFactory listenerContainerFactory; + @Autowired + ConsumerFactory consumerFactory; + ConcurrentMessageListenerContainer listenerContainer; + public void changeTopic(String topic) throws InterruptedException { + log.info("Changing topic to: {}", topic); + if(listenerContainer != null) { + listenerContainer.stop(); + Thread.sleep(2000); + listenerContainer.destroy(); + Thread.sleep(2000); + } + ContainerProperties containerProperties = new ContainerProperties(topic); + containerProperties.setGroupId(RandomStringUtils.randomAlphanumeric(3)); + containerProperties.setMessageListener((MessageListener) message -> { + System.out.println("Kafka listener, topic: " + message.topic().toString() + ", message content: " + message.value().getContent()); + }); + listenerContainer = new ConcurrentMessageListenerContainer<>(consumerFactory, containerProperties); + listenerContainer.start(); + } + + public void createTopic(String topic) { + adminClient.createTopics(List.of(TopicBuilder.name(topic).build())); + } + + public void deleteTopic(String topic) { + adminClient.deleteTopics(List.of(topic)); + } +} diff --git a/src/main/java/com/example/chatapplication/configuration/kafka/KafkaTopics.java b/src/main/java/com/example/chatapplication/configuration/kafka/KafkaTopics.java new file mode 100644 index 0000000..f2faf48 --- /dev/null +++ b/src/main/java/com/example/chatapplication/configuration/kafka/KafkaTopics.java @@ -0,0 +1,40 @@ +package com.example.chatapplication.configuration.kafka; + +import org.apache.kafka.clients.KafkaClient; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.KafkaAdminClient; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; +import org.springframework.kafka.core.KafkaAdmin; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaTopics { + public static final String TOPIC1 = "message-topic1"; + public static final String TOPIC2 = "message-topic2"; + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServer; + + @Bean + public NewTopic topic1() { + return TopicBuilder.name(TOPIC1).build(); + } + + @Bean + public NewTopic topic2() { + return TopicBuilder.name(TOPIC2).build(); + } + + @Bean + public AdminClient kafkaAdminClient() { + Map configurations = new HashMap<>(); + configurations.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServer); + return AdminClient.create(configurations); + } +} diff --git a/src/main/java/com/example/chatapplication/configuration/kafka/ProducerKafkaConfiguration.java b/src/main/java/com/example/chatapplication/configuration/kafka/ProducerKafkaConfiguration.java new file mode 100644 index 0000000..ed89b9f --- /dev/null +++ b/src/main/java/com/example/chatapplication/configuration/kafka/ProducerKafkaConfiguration.java @@ -0,0 +1,43 @@ +package com.example.chatapplication.configuration.kafka; + +import com.example.chatapplication.chatroom.model.Message; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@EnableKafka +@Configuration +public class ProducerKafkaConfiguration { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServer; + + public Map producerConfiguration() { + Map configurations = new HashMap<>(); + configurations.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServer); + configurations.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + configurations.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + return configurations; + } + + @Bean + public ProducerFactory producerFactory() { + return new DefaultKafkaProducerFactory(producerConfiguration()); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} diff --git a/src/main/java/com/example/chatapplication/utils/Destinations.java b/src/main/java/com/example/chatapplication/utils/Destinations.java new file mode 100644 index 0000000..64a2f57 --- /dev/null +++ b/src/main/java/com/example/chatapplication/utils/Destinations.java @@ -0,0 +1,17 @@ +package com.example.chatapplication.utils; + +public class Destinations { + public static class Room { + public static String publicMessages(String roomId) { + return "/topic/" + roomId + ".public.messages"; + } + + public static String privateMessage(String roomId) { + return "/queue/" + roomId + ".private.messages"; + } + + public static String connectedUsers(String roomId) { + return "/topic/" + roomId + ".connected.users"; + } + } +} diff --git a/src/main/java/com/example/chatapplication/utils/SystemMessages.java b/src/main/java/com/example/chatapplication/utils/SystemMessages.java new file mode 100644 index 0000000..d0c79c7 --- /dev/null +++ b/src/main/java/com/example/chatapplication/utils/SystemMessages.java @@ -0,0 +1,20 @@ +package com.example.chatapplication.utils; + +import com.example.chatapplication.chatroom.model.Message; +import com.example.chatapplication.chatroom.model.MessageBuilder; + +public class SystemMessages { + public static final Message welcome(String roomId, String username) { + return new MessageBuilder() + .newMessage() + .toRoomId(roomId) + .systemMessage().withContent(username + " joined :D"); + } + + public static final Message goodbye(String roomId, String username) { + return new MessageBuilder() + .newMessage() + .toRoomId(roomId) + .systemMessage().withContent(username + " left :("); + } +} diff --git a/src/main/java/com/example/chatapplication/utils/SystemUsers.java b/src/main/java/com/example/chatapplication/utils/SystemUsers.java new file mode 100644 index 0000000..958e86b --- /dev/null +++ b/src/main/java/com/example/chatapplication/utils/SystemUsers.java @@ -0,0 +1,13 @@ +package com.example.chatapplication.utils; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@AllArgsConstructor +public enum SystemUsers { + ADMIN("admin"); + private String username; + +} diff --git a/src/main/resources/application-docker.properties b/src/main/resources/application-docker.properties new file mode 100644 index 0000000..79d8a5a --- /dev/null +++ b/src/main/resources/application-docker.properties @@ -0,0 +1,34 @@ +# OracleDB connection settings +spring.datasource.url=jdbc:oracle:thin:@host.docker.internal:1521:orcl +spring.datasource.username=dev +spring.datasource.password=123456 +spring.datasource.driver-class-name=oracle.jdbc.OracleDriver + +# HikariCP settings +spring.datasource.hikari.minimumIdle=5 +spring.datasource.hikari.maximumPoolSize=20 +spring.datasource.hikari.idleTimeout=30000 +spring.datasource.hikari.maxLifetime=2000000 +spring.datasource.hikari.connectionTimeout=30000 +spring.datasource.hikari.poolName=HikariPoolChatApplication + +# JPA settings +spring.jpa.hibernate.ddl-auto=update + +# Redis config +spring.redis.host=host.docker.internal +spring.redis.port=6381 +spring.data.redis.repositories.enabled=true + +# Kafka +spring.kafka.bootstrap-servers=host.docker.internal:9092 +spring.kafka.consumer.auto-offset-reset=earliest + +# Session +spring.session.store-type=redis + +# Relay host and port +chat.app.relay.host=host.docker.internal +chat.app.relay.port=61613 +chat.app.relay.username=admin +chat.app.relay.password=admin \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..c244014 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,34 @@ +# OracleDB connection settings +spring.datasource.url=jdbc:oracle:thin:@localhost:1521:orcl +spring.datasource.username=dev +spring.datasource.password=123456 +spring.datasource.driver-class-name=oracle.jdbc.OracleDriver + +# HikariCP settings +spring.datasource.hikari.minimumIdle=5 +spring.datasource.hikari.maximumPoolSize=20 +spring.datasource.hikari.idleTimeout=30000 +spring.datasource.hikari.maxLifetime=2000000 +spring.datasource.hikari.connectionTimeout=30000 +spring.datasource.hikari.poolName=HikariPoolChatApplication + +# JPA settings +spring.jpa.hibernate.ddl-auto=update + +# Redis config +spring.redis.host=localhost +spring.redis.port=6381 +spring.data.redis.repositories.enabled=true + +# Kafka +spring.kafka.bootstrap-servers=host.docker.internal:9092 +spring.kafka.consumer.auto-offset-reset=earliest + +# Session +spring.session.store-type=redis + +# Relay host and port +chat.app.relay.host=localhost +chat.app.relay.port=61613 +chat.app.relay.username=admin +chat.app.relay.password=admin \ No newline at end of file diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css new file mode 100644 index 0000000..7a1d716 --- /dev/null +++ b/src/main/resources/static/css/main.css @@ -0,0 +1,19 @@ +.full-height { + height: 100vh; + display: flex; + flex-direction: column; +} + +body { + font-family: "Poppins", Arial, Helvetica, sans-serif; + font-size: 13px; + color: #495057; +} + +.online { + color: #2ecc71; +} + +.offline { + color: dimgray; +} \ No newline at end of file diff --git a/src/main/resources/static/images/avatar.png b/src/main/resources/static/images/avatar.png new file mode 100644 index 0000000..e082974 Binary files /dev/null and b/src/main/resources/static/images/avatar.png differ diff --git a/src/main/resources/static/images/github.png b/src/main/resources/static/images/github.png new file mode 100644 index 0000000..f7bdf96 Binary files /dev/null and b/src/main/resources/static/images/github.png differ diff --git a/src/main/resources/static/images/networking.png b/src/main/resources/static/images/networking.png new file mode 100644 index 0000000..0b6faf8 Binary files /dev/null and b/src/main/resources/static/images/networking.png differ diff --git a/src/main/resources/static/images/redis_icon.png b/src/main/resources/static/images/redis_icon.png new file mode 100644 index 0000000..e94fb3b Binary files /dev/null and b/src/main/resources/static/images/redis_icon.png differ diff --git a/src/main/resources/static/images/sell.png b/src/main/resources/static/images/sell.png new file mode 100644 index 0000000..8217d1f Binary files /dev/null and b/src/main/resources/static/images/sell.png differ diff --git a/src/main/resources/static/images/welcome-back.png b/src/main/resources/static/images/welcome-back.png new file mode 100644 index 0000000..32c08db Binary files /dev/null and b/src/main/resources/static/images/welcome-back.png differ diff --git a/src/main/resources/static/js/join-room.js b/src/main/resources/static/js/join-room.js new file mode 100644 index 0000000..e062550 --- /dev/null +++ b/src/main/resources/static/js/join-room.js @@ -0,0 +1,149 @@ +let socket; +let stompClient; +let roomId; +let chatBox; +let messageField; +let btnSend; +let messageForm; +let username; + +$(document).ready(function () { + roomId = $("#roomId").text(); + chatBox = $("#chatBox"); + messageField = $("#messageField"); + btnSend = $("#btnSend"); + messageForm = $("#messageForm"); + username = $("#username"); + + console.log("Room ID: " + roomId); + connect(); + messageForm.on("submit", function (e) { + e.preventDefault(); + sendMessage(); + }); +}); +function connect() { + console.log("Connecting to websocket....") + socket = new SockJS("/ws") + stompClient = Stomp.over(socket); + stompClient.connect( + {'roomId': roomId}, + stompSuccess, + stompFailure + ); +} + +function stompSuccess() { + console.log("Successfully connect to websocket"); + stompClient.subscribe("/room/connected.users", updateConnectedUsers()); + stompClient.subscribe('/room/old.messages', oldMessages); + stompClient.subscribe('/topic/' + roomId + '.public.messages', publicMessages); +} + +function stompFailure() { + console.log("Failed to connect to websocket"); +} + +function updateConnectedUsers(response) { + console.log("Getting connected user successfully"); + // let connectedUsers = JSON.parse(response.body); + // console.log(connectedUsers); +} + +function publicMessages(message) { + console.log("Handling public message"); + let publicMessage = JSON.parse(message.body); + appendMessage(publicMessage); +} + +function appendMessage(message) { + if(message.fromUser == "admin") { + appendPublicMessage(message); + return; + } + let usernameValue = username.text(); + let formattedTime = $.format.date(message.dateTime, 'dd/MM/yyyy HH:mm'); + if(message.fromUser == usernameValue) { + chatBox.append( + `
+ +
+ +
+
+
+ +
${message.fromUser}
+ +

${message.content}

+ + ${formattedTime} +
+
+
+
` + ); + } else { + chatBox.append( + `
+ +
+
+
+ +
${message.fromUser}
+ +

${message.content}

+ +

${formattedTime}

+
+
+
+ + +
+
` + ) + } + scrollDownMessagesPanel(); +} +function appendPublicMessage(publicMessage) { + chatBox.append( + '
System: '+publicMessage.content+'
' + ); + scrollDownMessagesPanel(); +} +function oldMessages(response) { + console.log("Handling old message"); + let messages = JSON.parse(response.body); + $.each(messages, function (index, message) { + appendMessage(message); + }); +} + +function sendMessage() { + if(messageField.val().trim().length === 0 ) { + messageField.focus(); + return; + } + + let messageObject = { + 'username': null, + 'roomId': null, + 'dateTime': null, + 'fromUser': null, + 'content': messageField.val(), + 'toUser': null + }; + stompClient.send('/room/send.message',{}, JSON.stringify(messageObject)); + messageField.val('').focus(); + +} + +function emptyInputMessage() { + messageField.val(""); +} + +function scrollDownMessagesPanel() { + chatBox.animate({"scrollTop": chatBox[0].scrollHeight}, "fast"); +} \ No newline at end of file diff --git a/src/main/resources/static/js/jquery-dateformat.js b/src/main/resources/static/js/jquery-dateformat.js new file mode 100644 index 0000000..5f50e97 --- /dev/null +++ b/src/main/resources/static/js/jquery-dateformat.js @@ -0,0 +1,495 @@ +var DateFormat = {}; + +(function($) { + var daysInWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + var shortDaysInWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + var shortMonthsInYear = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + var longMonthsInYear = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + var shortMonthsToNumber = { 'Jan': '01', 'Feb': '02', 'Mar': '03', 'Apr': '04', 'May': '05', 'Jun': '06', + 'Jul': '07', 'Aug': '08', 'Sep': '09', 'Oct': '10', 'Nov': '11', 'Dec': '12' }; + + var YYYYMMDD_MATCHER = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.?\d{0,3}[Z\-+]?(\d{2}:?\d{2})?/; + + $.format = (function() { + function numberToLongDay(value) { + // 0 to Sunday + // 1 to Monday + return daysInWeek[parseInt(value, 10)] || value; + } + + function numberToShortDay(value) { + // 0 to Sun + // 1 to Mon + return shortDaysInWeek[parseInt(value, 10)] || value; + } + + function numberToShortMonth(value) { + // 1 to Jan + // 2 to Feb + var monthArrayIndex = parseInt(value, 10) - 1; + return shortMonthsInYear[monthArrayIndex] || value; + } + + function numberToLongMonth(value) { + // 1 to January + // 2 to February + var monthArrayIndex = parseInt(value, 10) - 1; + return longMonthsInYear[monthArrayIndex] || value; + } + + function shortMonthToNumber(value) { + // Jan to 01 + // Feb to 02 + return shortMonthsToNumber[value] || value; + } + + function parseTime(value) { + // 10:54:50.546 + // => hour: 10, minute: 54, second: 50, millis: 546 + // 10:54:50 + // => hour: 10, minute: 54, second: 50, millis: '' + var time = value, + hour, + minute, + second, + millis = '', + delimited, + timeArray; + + if(time.indexOf('.') !== -1) { + delimited = time.split('.'); + // split time and milliseconds + time = delimited[0]; + millis = delimited[delimited.length - 1]; + } + + timeArray = time.split(':'); + + if(timeArray.length >= 3) { + hour = timeArray[0]; + minute = timeArray[1]; + // '20 GMT-0200 (BRST)'.replace(/\s.+/, '').replace(/[a-z]/gi, ''); + // => 20 + // '20Z'.replace(/\s.+/, '').replace(/[a-z]/gi, ''); + // => 20 + second = timeArray[2].replace(/\s.+/, '').replace(/[a-z]/gi, ''); + // '01:10:20 GMT-0200 (BRST)'.replace(/\s.+/, '').replace(/[a-z]/gi, ''); + // => 01:10:20 + // '01:10:20Z'.replace(/\s.+/, '').replace(/[a-z]/gi, ''); + // => 01:10:20 + time = time.replace(/\s.+/, '').replace(/[a-z]/gi, ''); + return { + time: time, + hour: hour, + minute: minute, + second: second, + millis: millis + }; + } + + return { time : '', hour : '', minute : '', second : '', millis : '' }; + } + + + function padding(value, length) { + var paddingCount = length - String(value).length; + for(var i = 0; i < paddingCount; i++) { + value = '0' + value; + } + return value; + } + + return { + + parseDate: function(value) { + var values, + subValues; + + var parsedDate = { + date: null, + year: null, + month: null, + dayOfMonth: null, + dayOfWeek: null, + time: null + }; + + if(typeof value == 'number') { + return this.parseDate(new Date(value)); + } else if(typeof value.getFullYear == 'function') { + parsedDate.year = String(value.getFullYear()); + // d = new Date(1900, 1, 1) // 1 for Feb instead of Jan. + // => Thu Feb 01 1900 00:00:00 + parsedDate.month = String(value.getMonth() + 1); + parsedDate.dayOfMonth = String(value.getDate()); + parsedDate.time = parseTime(value.toTimeString() + '.' + value.getMilliseconds()); + } else if(value.search(YYYYMMDD_MATCHER) != -1) { + /* 2009-04-19T16:11:05+02:00 || 2009-04-19T16:11:05Z */ + values = value.split(/[T\+-]/); + parsedDate.year = values[0]; + parsedDate.month = values[1]; + parsedDate.dayOfMonth = values[2]; + parsedDate.time = parseTime(values[3].split('.')[0]); + } else { + values = value.split(' '); + if(values.length === 6 && isNaN(values[5])) { + // values[5] == year + /* + * This change is necessary to make `Mon Apr 28 2014 05:30:00 GMT-0300` work + * like `case 7` + * otherwise it will be considered like `Wed Jan 13 10:43:41 CET 2010 + * Fixes: https://github.com/phstc/jquery-dateFormat/issues/64 + */ + values[values.length] = '()'; + } + switch (values.length) { + case 6: + /* Wed Jan 13 10:43:41 CET 2010 */ + parsedDate.year = values[5]; + parsedDate.month = shortMonthToNumber(values[1]); + parsedDate.dayOfMonth = values[2]; + parsedDate.time = parseTime(values[3]); + break; + case 2: + /* 2009-12-18 10:54:50.546 */ + subValues = values[0].split('-'); + parsedDate.year = subValues[0]; + parsedDate.month = subValues[1]; + parsedDate.dayOfMonth = subValues[2]; + parsedDate.time = parseTime(values[1]); + break; + case 7: + /* Tue Mar 01 2011 12:01:42 GMT-0800 (PST) */ + case 9: + /* added by Larry, for Fri Apr 08 2011 00:00:00 GMT+0800 (China Standard Time) */ + case 10: + /* added by Larry, for Fri Apr 08 2011 00:00:00 GMT+0200 (W. Europe Daylight Time) */ + parsedDate.year = values[3]; + /* edited by Andrey, for Mon 18 Apr 2016 -//-: '[Day] [Month]' format (russian) */ + var parsedVal1 = parseInt(values[1]); + var parsedVal2 = parseInt(values[2]); + if (parsedVal1 && !parsedVal2) { + parsedDate.month = shortMonthToNumber(values[2]); + parsedDate.dayOfMonth = values[1]; + } else { + parsedDate.month = shortMonthToNumber(values[1]); + parsedDate.dayOfMonth = values[2]; + } + parsedDate.time = parseTime(values[4]); + break; + case 1: + /* added by Jonny, for 2012-02-07CET00:00:00 (Doctrine Entity -> Json Serializer) */ + subValues = values[0].split(''); + parsedDate.year = subValues[0] + subValues[1] + subValues[2] + subValues[3]; + parsedDate.month = subValues[5] + subValues[6]; + parsedDate.dayOfMonth = subValues[8] + subValues[9]; + parsedDate.time = parseTime(subValues[13] + subValues[14] + subValues[15] + subValues[16] + subValues[17] + subValues[18] + subValues[19] + subValues[20]); + break; + default: + return null; + } + } + + if(parsedDate.time) { + parsedDate.date = new Date(parsedDate.year, parsedDate.month - 1, parsedDate.dayOfMonth, parsedDate.time.hour, parsedDate.time.minute, parsedDate.time.second, parsedDate.time.millis); + } else { + parsedDate.date = new Date(parsedDate.year, parsedDate.month - 1, parsedDate.dayOfMonth); + } + + parsedDate.dayOfWeek = String(parsedDate.date.getDay()); + + return parsedDate; + }, + + date : function(value, format) { + try { + var parsedDate = this.parseDate(value); + + if(parsedDate === null) { + return value; + } + + var year = parsedDate.year, + month = parsedDate.month, + dayOfMonth = parsedDate.dayOfMonth, + dayOfWeek = parsedDate.dayOfWeek, + time = parsedDate.time; + var hour; + + var pattern = '', + retValue = '', + unparsedRest = '', + inQuote = false; + + /* Issue 1 - variable scope issue in format.date (Thanks jakemonO) */ + for(var i = 0; i < format.length; i++) { + var currentPattern = format.charAt(i); + // Look-Ahead Right (LALR) + var nextRight = format.charAt(i + 1); + + if (inQuote) { + if (currentPattern == "'") { + retValue += (pattern === '') ? "'" : pattern; + pattern = ''; + inQuote = false; + } else { + pattern += currentPattern; + } + continue; + } + pattern += currentPattern; + unparsedRest = ''; + switch (pattern) { + case 'ddd': + retValue += numberToLongDay(dayOfWeek); + pattern = ''; + break; + case 'dd': + if(nextRight === 'd') { + break; + } + retValue += padding(dayOfMonth, 2); + pattern = ''; + break; + case 'd': + if(nextRight === 'd') { + break; + } + retValue += parseInt(dayOfMonth, 10); + pattern = ''; + break; + case 'D': + if(dayOfMonth == 1 || dayOfMonth == 21 || dayOfMonth == 31) { + dayOfMonth = parseInt(dayOfMonth, 10) + 'st'; + } else if(dayOfMonth == 2 || dayOfMonth == 22) { + dayOfMonth = parseInt(dayOfMonth, 10) + 'nd'; + } else if(dayOfMonth == 3 || dayOfMonth == 23) { + dayOfMonth = parseInt(dayOfMonth, 10) + 'rd'; + } else { + dayOfMonth = parseInt(dayOfMonth, 10) + 'th'; + } + retValue += dayOfMonth; + pattern = ''; + break; + case 'MMMM': + retValue += numberToLongMonth(month); + pattern = ''; + break; + case 'MMM': + if(nextRight === 'M') { + break; + } + retValue += numberToShortMonth(month); + pattern = ''; + break; + case 'MM': + if(nextRight === 'M') { + break; + } + retValue += padding(month, 2); + pattern = ''; + break; + case 'M': + if(nextRight === 'M') { + break; + } + retValue += parseInt(month, 10); + pattern = ''; + break; + case 'y': + case 'yyy': + if(nextRight === 'y') { + break; + } + retValue += pattern; + pattern = ''; + break; + case 'yy': + if(nextRight === 'y') { + break; + } + retValue += String(year).slice(-2); + pattern = ''; + break; + case 'yyyy': + retValue += year; + pattern = ''; + break; + case 'HH': + retValue += padding(time.hour, 2); + pattern = ''; + break; + case 'H': + if(nextRight === 'H') { + break; + } + retValue += parseInt(time.hour, 10); + pattern = ''; + break; + case 'hh': + /* time.hour is '00' as string == is used instead of === */ + hour = (parseInt(time.hour, 10) === 0 ? 12 : time.hour < 13 ? time.hour + : time.hour - 12); + retValue += padding(hour, 2); + pattern = ''; + break; + case 'h': + if(nextRight === 'h') { + break; + } + hour = (parseInt(time.hour, 10) === 0 ? 12 : time.hour < 13 ? time.hour + : time.hour - 12); + retValue += parseInt(hour, 10); + // Fixing issue https://github.com/phstc/jquery-dateFormat/issues/21 + // retValue = parseInt(retValue, 10); + pattern = ''; + break; + case 'mm': + retValue += padding(time.minute, 2); + pattern = ''; + break; + case 'm': + if(nextRight === 'm') { + break; + } + retValue += parseInt(time.minute,10); + pattern = ''; + break; + case 'ss': + /* ensure only seconds are added to the return string */ + retValue += padding(time.second.substring(0, 2), 2); + pattern = ''; + break; + case 's': + if(nextRight === 's') { + break; + } + retValue += parseInt(time.second,10); + pattern = ''; + break; + case 'S': + case 'SS': + if(nextRight === 'S') { + break; + } + retValue += pattern; + pattern = ''; + break; + case 'SSS': + retValue += padding(time.millis.substring(0, 3), 3); + pattern = ''; + break; + case 'a': + retValue += time.hour >= 12 ? 'PM' : 'AM'; + pattern = ''; + break; + case 'p': + retValue += time.hour >= 12 ? 'p.m.' : 'a.m.'; + pattern = ''; + break; + case 'E': + retValue += numberToShortDay(dayOfWeek); + pattern = ''; + break; + case "'": + pattern = ''; + inQuote = true; + break; + default: + retValue += currentPattern; + pattern = ''; + break; + } + } + retValue += unparsedRest; + return retValue; + } catch (e) { + if(console && console.log) { + console.log(e); + } + return value; + } + }, + /* + * JavaScript Pretty Date + * Copyright (c) 2011 John Resig (ejohn.org) + * Licensed under the MIT and GPL licenses. + * + * Takes an ISO time and returns a string representing how long ago the date + * represents + * + * ('2008-01-28T20:24:17Z') // => '2 hours ago' + * ('2008-01-27T22:24:17Z') // => 'Yesterday' + * ('2008-01-26T22:24:17Z') // => '2 days ago' + * ('2008-01-14T22:24:17Z') // => '2 weeks ago' + * ('2007-12-15T22:24:17Z') // => 'more than 5 weeks ago' + * + */ + prettyDate : function(time) { + var date; + var diff; + var abs_diff; + var day_diff; + var abs_day_diff; + var tense; + + if(typeof time === 'string' || typeof time === 'number') { + date = new Date(time); + } + + if(typeof time === 'object') { + date = new Date(time.toString()); + } + + diff = (((new Date()).getTime() - date.getTime()) / 1000); + + abs_diff = Math.abs(diff); + abs_day_diff = Math.floor(abs_diff / 86400); + + if(isNaN(abs_day_diff)) { + return; + } + + tense = diff < 0 ? 'from now' : 'ago'; + + if(abs_diff < 60) { + if(diff >= 0) + return 'just now'; + else + return 'in a moment'; + } else if(abs_diff < 120) { + return '1 minute ' + tense; + } else if(abs_diff < 3600) { + return Math.floor(abs_diff / 60) + ' minutes ' + tense; + } else if(abs_diff < 7200) { + return '1 hour ' + tense; + } else if(abs_diff < 86400) { + return Math.floor(abs_diff / 3600) + ' hours ' + tense; + } else if(abs_day_diff === 1) { + if(diff >= 0) + return 'Yesterday'; + else + return 'Tomorrow'; + } else if(abs_day_diff < 7) { + return abs_day_diff + ' days ' + tense; + } else if(abs_day_diff === 7) { + return '1 week ' + tense; + } else if(abs_day_diff < 31) { + return Math.ceil(abs_day_diff / 7) + ' weeks ' + tense; + } else { + return 'more than 5 weeks ' + tense; + } + }, + toBrowserTimeZone : function(value, format) { + return this.date(new Date(value), format || 'MM/dd/yyyy HH:mm:ss'); + } + }; + }()); +}(DateFormat)); +;// require dateFormat.js +// please check `dist/jquery.dateFormat.js` for a complete version +(function($) { + $.format = DateFormat.format; +}(jQuery)); diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html new file mode 100644 index 0000000..9419b45 --- /dev/null +++ b/src/main/resources/templates/chat.html @@ -0,0 +1,172 @@ + + + + + + + + + + + Chat + + +
+ + + +
+
+
+
+
+

Chats

+ + +
+ + +
+
+ +
+
+ +
+
+

+
+
+
chat
+

+
last message
+
+
+ 5:01 PM +
+
+
+ +
+
+
+
+ +
+
+
My Name
+ Active +
+
+
+ +
+
+
+
+
+ + +
+ +
+
+

General

+ + +
+
+ + +
+ + +
+ + +
+
+ +
+ +
+ + +
+ +
+
+
+
+
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..5c28bdd --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,63 @@ + + + + + + + + + Login + + +
+ + +
+
+
+
+
+

Welcome back !

+

Sign in to continue

+
+
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+ Create new account +
+
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/new-account.html b/src/main/resources/templates/new-account.html new file mode 100644 index 0000000..2b65e7c --- /dev/null +++ b/src/main/resources/templates/new-account.html @@ -0,0 +1,81 @@ + + + + + + + + + Create New Account + + +
+ + + +
+
+
+
+
+

Create new account !

+

Fill the form

+
+
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+ +

Error

+
+
+
+ +
+ +

Error

+
+
+
+ +
+ +

Error

+
+
+
+ +
+ +

Error

+
+
+ +
+
+
+
+ + \ No newline at end of file diff --git a/src/test/java/com/example/chatapplication/ChatApplicationTests.java b/src/test/java/com/example/chatapplication/ChatApplicationTests.java new file mode 100644 index 0000000..922e66e --- /dev/null +++ b/src/test/java/com/example/chatapplication/ChatApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.chatapplication; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ChatApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/example/chatapplication/RoomRepositoryTest.java b/src/test/java/com/example/chatapplication/RoomRepositoryTest.java new file mode 100644 index 0000000..f02aaff --- /dev/null +++ b/src/test/java/com/example/chatapplication/RoomRepositoryTest.java @@ -0,0 +1,32 @@ +package com.example.chatapplication; + +import com.example.chatapplication.chatroom.model.Room; +import com.example.chatapplication.chatroom.repository.RoomRepository; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.AutoConfigureDataRedis; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; + +import java.util.Optional; + +@DataRedisTest +public class RoomRepositoryTest { + @Autowired + private RoomRepository roomRepository; + + @Test + public void testCreateRoom() { + String id = "456"; + String name = "Trò chuyện linh tinh"; + String description = "Truyện trò tâm sự đủ thứ"; + Room room = new Room(); + room.setId(id); + room.setName(name); + room.setDescription(description); + + Room savedRoom = roomRepository.save(room); + Optional optionalRoom = roomRepository.findById(id); + Assertions.assertThat(optionalRoom.isPresent()).isTrue(); + } +} diff --git a/src/test/java/com/example/chatapplication/UserRepositoryTest.java b/src/test/java/com/example/chatapplication/UserRepositoryTest.java new file mode 100644 index 0000000..7bd6b9f --- /dev/null +++ b/src/test/java/com/example/chatapplication/UserRepositoryTest.java @@ -0,0 +1,44 @@ +package com.example.chatapplication; + +import com.example.chatapplication.authentication.model.Role; +import com.example.chatapplication.authentication.repository.RoleRepository; +import com.example.chatapplication.authentication.repository.UserRepository; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.annotation.Rollback; + +import java.util.Optional; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Rollback(value = false) +public class UserRepositoryTest { + @Autowired + private UserRepository userRepository; + @Autowired + private RoleRepository roleRepository; + + @Test + public void getRoleTest() { + int id = 3; + Optional optionalRole = roleRepository.findById(id); + + Assertions.assertThat(optionalRole.isPresent()).isTrue(); + } + + @Test + public void createRoleTest() { + Role role = new Role(); + role.setName("USER"); + + Role savedRole = roleRepository.save(role); + Assertions.assertThat(savedRole.getId()).isNotNull(); + + Optional optionalRole = roleRepository.findById(savedRole.getId()); + Assertions.assertThat(optionalRole.isPresent()).isTrue(); + } +}