├── start.sh ├── nginx ├── data │ └── .gitignore ├── nginx-certbot.env.example └── user_conf.d │ └── server.conf ├── postgresql └── .gitignore ├── redis └── data │ └── .gitignore ├── .gitattributes ├── architecture.png ├── demo_group_chat.png ├── demo_private_chat.png ├── demo_public_chat.png ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ ├── maven-wrapper.properties │ └── MavenWrapperDownloader.java ├── doc └── chat-detail │ ├── group-chat.png │ ├── public-chat.png │ ├── private-chat.png │ └── README.md ├── Dockerfile ├── src ├── main │ ├── resources │ │ ├── application-dev.yml │ │ ├── db │ │ │ ├── changelog │ │ │ │ ├── 2023 │ │ │ │ │ ├── 10 │ │ │ │ │ │ ├── 10-02-changelog.yaml │ │ │ │ │ │ ├── 10-01-changelog.yaml │ │ │ │ │ │ ├── 10-03-changelog.yaml │ │ │ │ │ │ └── 10-04-changelog.yaml │ │ │ │ │ └── 06 │ │ │ │ │ │ └── 06-01-changelog.yaml │ │ │ │ └── db.changelog-master.yaml │ │ │ └── test │ │ │ │ └── changelog │ │ │ │ ├── 2023 │ │ │ │ ├── 10 │ │ │ │ │ ├── 10-02-changelog.yaml │ │ │ │ │ ├── 10-01-changelog.yaml │ │ │ │ │ ├── 10-03-changelog.yaml │ │ │ │ │ └── 10-04-changelog.yaml │ │ │ │ └── 06 │ │ │ │ │ └── 06-01-changelog.yaml │ │ │ │ └── db.changelog-master.yaml │ │ ├── application-dev.properties │ │ ├── application-test.properties │ │ └── application-test.yml │ └── java │ │ └── com │ │ └── joejoe2 │ │ └── chat │ │ ├── exception │ │ ├── AlreadyExist.java │ │ ├── BlockedException.java │ │ ├── InvalidOperation.java │ │ ├── UserDoesNotExist.java │ │ ├── ValidationError.java │ │ ├── ChannelDoesNotExist.java │ │ ├── InvalidTokenException.java │ │ └── ControllerConstraintViolation.java │ │ ├── validation │ │ ├── validator │ │ │ ├── Validator.java │ │ │ ├── UUIDValidator.java │ │ │ ├── MessageValidator.java │ │ │ ├── PageRequestValidator.java │ │ │ └── ChannelNameValidator.java │ │ └── constraint │ │ │ ├── UUID.java │ │ │ ├── Message.java │ │ │ └── PublicChannelName.java │ │ ├── data │ │ ├── ErrorMessageResponse.java │ │ ├── InvalidRequestResponse.java │ │ ├── channel │ │ │ ├── request │ │ │ │ ├── SubscribeRequest.java │ │ │ │ ├── CreateChannelByNameRequest.java │ │ │ │ ├── ChannelRequest.java │ │ │ │ ├── CreatePrivateChannelRequest.java │ │ │ │ ├── ChannelBlockRequest.java │ │ │ │ ├── ChannelUserRequest.java │ │ │ │ ├── SubscribeChannelRequest.java │ │ │ │ ├── ChannelPageRequest.java │ │ │ │ ├── EditBannedRequest.java │ │ │ │ ├── EditAdminRequest.java │ │ │ │ └── ChannelPageRequestWithSince.java │ │ │ ├── profile │ │ │ │ ├── PublicChannelProfile.java │ │ │ │ ├── GroupChannelProfile.java │ │ │ │ └── PrivateChannelProfile.java │ │ │ ├── SliceOfGroupChannel.java │ │ │ ├── SliceOfPrivateChannel.java │ │ │ └── PageOfChannel.java │ │ ├── SliceList.java │ │ ├── PageList.java │ │ ├── message │ │ │ ├── SliceOfMessage.java │ │ │ ├── request │ │ │ │ └── PublishMessageRequest.java │ │ │ ├── GroupMessageDto.java │ │ │ ├── PublicMessageDto.java │ │ │ ├── PrivateMessageDto.java │ │ │ └── MessageDto.java │ │ ├── UserPublicProfile.java │ │ ├── PageRequest.java │ │ ├── PageRequestWithSince.java │ │ └── UserDetail.java │ │ ├── service │ │ ├── redis │ │ │ ├── RedisService.java │ │ │ └── RedisServiceImpl.java │ │ ├── jwt │ │ │ ├── JwtService.java │ │ │ └── JwtServiceImpl.java │ │ ├── nats │ │ │ ├── NatsService.java │ │ │ └── NatsServiceImpl.java │ │ ├── message │ │ │ ├── PublicMessageService.java │ │ │ ├── GroupMessageService.java │ │ │ ├── PrivateMessageService.java │ │ │ └── PublicMessageServiceImpl.java │ │ ├── channel │ │ │ ├── PublicChannelService.java │ │ │ └── PrivateChannelService.java │ │ └── user │ │ │ └── UserService.java │ │ ├── config │ │ ├── RetryConfig.java │ │ ├── JwtConfig.java │ │ ├── AsyncConfig.java │ │ ├── PublicKeyConverter.java │ │ ├── NatsConfig.java │ │ ├── PrivateKeyConverter.java │ │ ├── InterceptorConfig.java │ │ ├── RedisConfig.java │ │ ├── CacheConfig.java │ │ ├── StringTrimConfig.java │ │ ├── WebSocketConfig.java │ │ ├── SpringDocConfig.java │ │ └── SecurityConfig.java │ │ ├── utils │ │ ├── TimeUtil.java │ │ ├── SseUtil.java │ │ ├── IPUtils.java │ │ ├── WebSocketUtil.java │ │ ├── ChannelSubject.java │ │ ├── HttpUtil.java │ │ ├── AuthUtil.java │ │ └── JwtUtil.java │ │ ├── models │ │ ├── MessageType.java │ │ ├── TimeStampBase.java │ │ ├── UUIDv7Generator.java │ │ ├── Base.java │ │ ├── GroupInvitationKey.java │ │ ├── User.java │ │ ├── PublicChannel.java │ │ ├── PublicMessage.java │ │ ├── GroupInvitation.java │ │ ├── PrivateMessage.java │ │ ├── PrivateChannel.java │ │ └── GroupMessage.java │ │ ├── controller │ │ ├── constraint │ │ │ ├── auth │ │ │ │ └── AuthenticatedApi.java │ │ │ └── checker │ │ │ │ └── ControllerAuthConstraintChecker.java │ │ ├── GroupChannelWSHandler.java │ │ ├── PrivateChannelWSHandler.java │ │ ├── PublicChannelWSHandler.java │ │ └── GlobalExceptionHandler.java │ │ ├── repository │ │ ├── user │ │ │ └── UserRepository.java │ │ ├── channel │ │ │ ├── PublicChannelRepository.java │ │ │ ├── GroupChannelRepository.java │ │ │ └── PrivateChannelRepository.java │ │ └── message │ │ │ ├── PublicMessageRepository.java │ │ │ ├── GroupMessageRepository.java │ │ │ └── PrivateMessageRepository.java │ │ ├── ChatApplication.java │ │ ├── interceptor │ │ ├── AuthenticatedHandshakeInterceptor.java │ │ └── ControllerConstraintInterceptor.java │ │ └── filter │ │ └── JwtAuthenticationFilter.java └── test │ └── java │ └── com │ └── joejoe2 │ └── chat │ ├── utils │ ├── TimeUtilTest.java │ ├── IPUtilsTest.java │ └── HttpUtilTest.java │ ├── service │ ├── redis │ │ └── RedisServiceTest.java │ └── channel │ │ └── PublicChannelServiceTest.java │ └── TestContext.java ├── jwtRS256.sh ├── .github └── workflows │ ├── pull.yml │ ├── branch.yml │ ├── main.yml │ └── release.yml ├── .gitignore ├── env ├── application-caht.env.example └── application.env.example ├── LICENSE ├── .jpb └── jpb-settings.xml ├── docker-compose.yaml └── README.md /start.sh: -------------------------------------------------------------------------------- 1 | java -jar ./chat.jar 2 | -------------------------------------------------------------------------------- /nginx/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /postgresql/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /redis/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joejoe2/spring-chat/HEAD/architecture.png -------------------------------------------------------------------------------- /demo_group_chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joejoe2/spring-chat/HEAD/demo_group_chat.png -------------------------------------------------------------------------------- /demo_private_chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joejoe2/spring-chat/HEAD/demo_private_chat.png -------------------------------------------------------------------------------- /demo_public_chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joejoe2/spring-chat/HEAD/demo_public_chat.png -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joejoe2/spring-chat/HEAD/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /doc/chat-detail/group-chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joejoe2/spring-chat/HEAD/doc/chat-detail/group-chat.png -------------------------------------------------------------------------------- /doc/chat-detail/public-chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joejoe2/spring-chat/HEAD/doc/chat-detail/public-chat.png -------------------------------------------------------------------------------- /doc/chat-detail/private-chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joejoe2/spring-chat/HEAD/doc/chat-detail/private-chat.png -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:17-jre 2 | EXPOSE 8080 3 | COPY start.sh wait-for-it.sh . 4 | RUN chmod +x start.sh && chmod +x wait-for-it.sh 5 | COPY ./target/chat.jar chat.jar 6 | -------------------------------------------------------------------------------- /src/main/resources/application-dev.yml: -------------------------------------------------------------------------------- 1 | jwt: 2 | secret: 3 | publicKey: | 4 | -----BEGIN PUBLIC KEY----- 5 | ... your PUBLIC KEY ... 6 | -----END PUBLIC KEY----- 7 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/exception/AlreadyExist.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.exception; 2 | 3 | public class AlreadyExist extends Exception { 4 | public AlreadyExist(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/exception/BlockedException.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.exception; 2 | 3 | public class BlockedException extends Exception { 4 | public BlockedException(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/exception/InvalidOperation.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.exception; 2 | 3 | public class InvalidOperation extends Exception { 4 | public InvalidOperation(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/exception/UserDoesNotExist.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.exception; 2 | 3 | public class UserDoesNotExist extends Exception { 4 | public UserDoesNotExist(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/exception/ValidationError.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.exception; 2 | 3 | public class ValidationError extends IllegalArgumentException { 4 | public ValidationError(String msg) { 5 | super(msg); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jwtRS256.sh: -------------------------------------------------------------------------------- 1 | # generate private key 2 | openssl genrsa -out private.pem 2048 3 | # extatract public key from it 4 | openssl rsa -in private.pem -pubout > public.key 5 | openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.pem -out private.key -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/exception/ChannelDoesNotExist.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.exception; 2 | 3 | public class ChannelDoesNotExist extends Exception { 4 | public ChannelDoesNotExist(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.3/apache-maven-3.8.3-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar 3 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/exception/InvalidTokenException.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.exception; 2 | 3 | public class InvalidTokenException extends Exception { 4 | public InvalidTokenException(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/validation/validator/Validator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.validation.validator; 2 | 3 | import com.joejoe2.chat.exception.ValidationError; 4 | 5 | public interface Validator { 6 | public abstract O validate(I data) throws ValidationError; 7 | } 8 | -------------------------------------------------------------------------------- /nginx/nginx-certbot.env.example: -------------------------------------------------------------------------------- 1 | # Required 2 | CERTBOT_EMAIL= 3 | 4 | # Optional (Defaults) 5 | STAGING=0 6 | DHPARAM_SIZE=2048 7 | RSA_KEY_SIZE=2048 8 | ELLIPTIC_CURVE=secp256r1 9 | USE_ECDSA=0 10 | RENEWAL_INTERVAL=1d 11 | 12 | # Advanced (Defaults) 13 | DEBUG=0 14 | USE_LOCAL_CA=0 15 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/ErrorMessageResponse.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class ErrorMessageResponse { 7 | String message; 8 | 9 | public ErrorMessageResponse(String message) { 10 | this.message = message; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/redis/RedisService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.redis; 2 | 3 | import java.time.Duration; 4 | import java.util.Optional; 5 | 6 | public interface RedisService { 7 | void set(String key, String value, Duration duration); 8 | 9 | Optional get(String key); 10 | 11 | boolean has(String key); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/RetryConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.core.Ordered; 5 | import org.springframework.retry.annotation.EnableRetry; 6 | 7 | @Configuration 8 | @EnableRetry(order = Ordered.HIGHEST_PRECEDENCE) 9 | public class RetryConfig {} 10 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/InvalidRequestResponse.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data; 2 | 3 | import java.util.Map; 4 | import java.util.TreeSet; 5 | import lombok.Data; 6 | 7 | @Data 8 | public class InvalidRequestResponse { 9 | Map> errors; 10 | 11 | public InvalidRequestResponse(Map> errors) { 12 | this.errors = errors; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/utils/TimeUtil.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.utils; 2 | 3 | import java.time.Instant; 4 | 5 | public class TimeUtil { 6 | public static Instant roundToMicro(Instant instant) { 7 | long nano = instant.getNano() % 1000; 8 | if (nano >= 500) { 9 | return instant.plusNanos(-nano).plusNanos(1000); 10 | } else { 11 | return instant.plusNanos(-nano); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/models/MessageType.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.models; 2 | 3 | public enum MessageType { 4 | MESSAGE("MESSAGE"), 5 | INVITATION("INVITATION"), 6 | JOIN("JOIN"), 7 | LEAVE("LEAVE"), 8 | BAN("BAN"), 9 | UNBAN("UNBAN"); 10 | 11 | private final String value; 12 | 13 | MessageType(String role) { 14 | this.value = role; 15 | } 16 | 17 | public String toString() { 18 | return value; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/pull.yml: -------------------------------------------------------------------------------- 1 | name: pull 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | check: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 # downloads a copy of repository 11 | 12 | - name: Setup JDK 17 13 | uses: actions/setup-java@v2 14 | with: 15 | java-version: '17' 16 | distribution: 'adopt' 17 | 18 | - name: Test 19 | run: mvn test 20 | 21 | - name: Build jar 22 | run: mvn package -Dmaven.test.skip=true -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/controller/constraint/auth/AuthenticatedApi.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.controller.constraint.auth; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import java.lang.annotation.ElementType; 6 | import java.lang.annotation.Retention; 7 | import java.lang.annotation.Target; 8 | 9 | @Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE}) 10 | @Retention(RUNTIME) 11 | public @interface AuthenticatedApi { 12 | String rejectMessage() default ""; 13 | 14 | int rejectStatus() default 401; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/models/TimeStampBase.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.models; 2 | 3 | import jakarta.persistence.MappedSuperclass; 4 | import java.time.Instant; 5 | import lombok.Data; 6 | import lombok.EqualsAndHashCode; 7 | import org.hibernate.annotations.CreationTimestamp; 8 | import org.hibernate.annotations.UpdateTimestamp; 9 | 10 | @EqualsAndHashCode(callSuper = true) 11 | @MappedSuperclass 12 | @Data 13 | public class TimeStampBase extends Base { 14 | @CreationTimestamp Instant createAt; 15 | 16 | @UpdateTimestamp Instant updateAt; 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/request/SubscribeRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.request; 2 | 3 | import io.swagger.v3.oas.annotations.Parameter; 4 | import jakarta.validation.constraints.NotEmpty; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Data 11 | @Builder 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class SubscribeRequest { 15 | @Parameter(description = "access token in query") 16 | @NotEmpty 17 | private String access_token; 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/SliceList.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data; 2 | 3 | import java.util.List; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | @Data 8 | @NoArgsConstructor 9 | public class SliceList { 10 | private int currentPage; 11 | private int pageSize; 12 | private List list; 13 | private boolean hasNext; 14 | 15 | public SliceList(int currentPage, int pageSize, List list, boolean hasNext) { 16 | this.currentPage = currentPage; 17 | this.pageSize = pageSize; 18 | this.list = list; 19 | this.hasNext = hasNext; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/db.changelog-master.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - include: 3 | file: db/changelog/2023/01/03-01-changelog.yaml 4 | - include: 5 | file: db/test/changelog/2023/03/03-01-changelog.yaml 6 | - include: 7 | file: db/test/changelog/2023/06/06-01-changelog.yaml 8 | - include: 9 | file: db/changelog/2023/10/10-01-changelog.yaml 10 | - include: 11 | file: db/changelog/2023/10/10-02-changelog.yaml 12 | - include: 13 | file: db/changelog/2023/10/10-03-changelog.yaml 14 | - include: 15 | file: db/changelog/2023/10/10-04-changelog.yaml -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/request/CreateChannelByNameRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.request; 2 | 3 | import com.joejoe2.chat.validation.constraint.PublicChannelName; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Data 11 | @Builder 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class CreateChannelByNameRequest { 15 | @Schema(description = "channel name") 16 | @PublicChannelName 17 | private String channelName; 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/repository/user/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.repository.user; 2 | 3 | import com.joejoe2.chat.models.User; 4 | import java.util.Optional; 5 | import java.util.UUID; 6 | import org.springframework.cache.annotation.Cacheable; 7 | import org.springframework.data.jpa.repository.JpaRepository; 8 | 9 | public interface UserRepository extends JpaRepository { 10 | @Override 11 | @Cacheable(value = "chat-users") 12 | boolean existsById(UUID uuid); 13 | 14 | Optional findById(UUID id); 15 | 16 | Optional getByUserName(String username); 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/exception/ControllerConstraintViolation.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.exception; 2 | 3 | public class ControllerConstraintViolation extends Exception { 4 | private final int rejectStatus; 5 | private final String rejectMessage; 6 | 7 | public ControllerConstraintViolation(int rejectStatus, String rejectMessage) { 8 | this.rejectStatus = rejectStatus; 9 | this.rejectMessage = rejectMessage; 10 | } 11 | 12 | public int getRejectStatus() { 13 | return rejectStatus; 14 | } 15 | 16 | public String getRejectMessage() { 17 | return rejectMessage; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/chat/utils/TimeUtilTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.utils; 2 | 3 | import static org.junit.jupiter.api.Assertions.*; 4 | 5 | import java.time.Instant; 6 | import org.junit.jupiter.api.Test; 7 | 8 | class TimeUtilTest { 9 | @Test 10 | void round() { 11 | Instant instant = Instant.now(); 12 | Instant up = Instant.parse("2023-04-17T04:53:13.123456600Z"); 13 | Instant down = Instant.parse("2023-04-17T04:53:13.123456400Z"); 14 | assertEquals(up.plusNanos(400), TimeUtil.roundToMicro(up)); 15 | assertEquals(down.plusNanos(-400), TimeUtil.roundToMicro(down)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/models/UUIDv7Generator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.models; 2 | 3 | import com.fasterxml.uuid.Generators; 4 | import java.io.Serializable; 5 | import org.hibernate.HibernateException; 6 | import org.hibernate.engine.spi.SharedSessionContractImplementor; 7 | import org.hibernate.id.IdentifierGenerator; 8 | 9 | public class UUIDv7Generator implements IdentifierGenerator { 10 | @Override 11 | public Serializable generate(SharedSessionContractImplementor session, Object object) 12 | throws HibernateException { 13 | return Generators.timeBasedEpochGenerator().generate(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/resources/db/test/changelog/db.changelog-master.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - include: 3 | file: db/test/changelog/2023/01/03-01-changelog.yaml 4 | - include: 5 | file: db/test/changelog/2023/03/03-01-changelog.yaml 6 | - include: 7 | file: db/test/changelog/2023/06/06-01-changelog.yaml 8 | - include: 9 | file: db/test/changelog/2023/10/10-01-changelog.yaml 10 | - include: 11 | file: db/test/changelog/2023/10/10-02-changelog.yaml 12 | - include: 13 | file: db/test/changelog/2023/10/10-03-changelog.yaml 14 | - include: 15 | file: db/test/changelog/2023/10/10-04-changelog.yaml -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/models/Base.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.models; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.GeneratedValue; 5 | import jakarta.persistence.Id; 6 | import jakarta.persistence.MappedSuperclass; 7 | import java.util.UUID; 8 | import lombok.Data; 9 | import org.hibernate.annotations.GenericGenerator; 10 | 11 | @MappedSuperclass 12 | @Data 13 | public class Base { 14 | @Id 15 | @GeneratedValue(generator = "UUIDv7") 16 | @GenericGenerator(name = "UUIDv7", strategy = "com.joejoe2.chat.models.UUIDv7Generator") 17 | @Column(unique = true, updatable = false, nullable = false) 18 | UUID id; 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/jwt/JwtService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.jwt; 2 | 3 | import com.joejoe2.chat.data.UserDetail; 4 | import com.joejoe2.chat.exception.InvalidTokenException; 5 | 6 | public interface JwtService { 7 | /** 8 | * retrieve UserDetail from access token 9 | * 10 | * @param token access token in plaintext 11 | * @return related UserDetail with the access token 12 | * @throws InvalidTokenException if the access token is invalid 13 | */ 14 | UserDetail getUserDetailFromAccessToken(String token) throws InvalidTokenException; 15 | 16 | boolean isAccessTokenInBlackList(String accessPlainToken); 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/ChatApplication.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat; 2 | 3 | import jakarta.annotation.PostConstruct; 4 | import java.util.Locale; 5 | import java.util.TimeZone; 6 | import org.springframework.boot.SpringApplication; 7 | import org.springframework.boot.autoconfigure.SpringBootApplication; 8 | 9 | @SpringBootApplication 10 | public class ChatApplication { 11 | public static void main(String[] args) { 12 | Locale.setDefault(Locale.ENGLISH); 13 | SpringApplication.run(ChatApplication.class, args); 14 | } 15 | 16 | @PostConstruct 17 | public void init() { 18 | TimeZone.setDefault(TimeZone.getTimeZone("UTC")); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/branch.yml: -------------------------------------------------------------------------------- 1 | name: branch-ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'v**' 7 | paths: 8 | - 'src/**' 9 | - 'pom.xml' 10 | - 'Dockerfile' 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 # downloads a copy of repository 19 | - name: Setup JDK 17 20 | uses: actions/setup-java@v2 21 | with: 22 | java-version: '17' 23 | distribution: 'adopt' 24 | 25 | - name: Test 26 | run: mvn test 27 | 28 | - name: Build jar 29 | run: mvn package -Dmaven.test.skip=true 30 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/repository/channel/PublicChannelRepository.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.repository.channel; 2 | 3 | import com.joejoe2.chat.models.PublicChannel; 4 | import java.util.Optional; 5 | import java.util.UUID; 6 | import org.springframework.data.domain.Page; 7 | import org.springframework.data.domain.Pageable; 8 | import org.springframework.data.jpa.repository.JpaRepository; 9 | 10 | public interface PublicChannelRepository extends JpaRepository { 11 | Optional findById(UUID id); 12 | 13 | Optional findByName(String name); 14 | 15 | Page findAll(Pageable pageable); 16 | } 17 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/2023/10/10-02-changelog.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: 1696227686045-3 4 | author: joejoe2 (generated) 5 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 6 | changes: 7 | - renameColumn: 8 | columnDataType: BOOLEAN 9 | newColumnName: is_first_user_blocked 10 | oldColumnName: is_user1blocked 11 | tableName: private_channel 12 | - renameColumn: 13 | columnDataType: BOOLEAN 14 | newColumnName: is_second_user_blocked 15 | oldColumnName: is_user2blocked 16 | tableName: private_channel 17 | 18 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/nats/NatsService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.nats; 2 | 3 | import com.joejoe2.chat.data.message.GroupMessageDto; 4 | import com.joejoe2.chat.data.message.PrivateMessageDto; 5 | import com.joejoe2.chat.data.message.PublicMessageDto; 6 | import io.nats.client.MessageHandler; 7 | import io.nats.client.Subscription; 8 | 9 | public interface NatsService { 10 | void publish(String subject, PrivateMessageDto message); 11 | 12 | void publish(String subject, PublicMessageDto message); 13 | 14 | Subscription subscribe(String subject, MessageHandler handler); 15 | 16 | void publish(String subject, GroupMessageDto message); 17 | } 18 | -------------------------------------------------------------------------------- /src/main/resources/db/test/changelog/2023/10/10-02-changelog.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: 1696227686045-3 4 | author: joejoe2 (generated) 5 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 6 | changes: 7 | - renameColumn: 8 | columnDataType: BOOLEAN 9 | newColumnName: is_first_user_blocked 10 | oldColumnName: is_user1blocked 11 | tableName: private_channel 12 | - renameColumn: 13 | columnDataType: BOOLEAN 14 | newColumnName: is_second_user_blocked 15 | oldColumnName: is_user2blocked 16 | tableName: private_channel 17 | 18 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/profile/PublicChannelProfile.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.profile; 2 | 3 | import com.joejoe2.chat.models.PublicChannel; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @NoArgsConstructor 10 | public class PublicChannelProfile { 11 | @Schema(description = "id of the channel") 12 | private String id; 13 | 14 | @Schema(description = "name of the channel") 15 | private String name; 16 | 17 | public PublicChannelProfile(PublicChannel channel) { 18 | id = channel.getId().toString(); 19 | name = channel.getName(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/PageList.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data; 2 | 3 | import java.util.List; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | @Data 8 | @NoArgsConstructor 9 | public class PageList { 10 | private long totalItems; 11 | private int currentPage; 12 | private int totalPages; 13 | private int pageSize; 14 | private List list; 15 | 16 | public PageList(long totalItems, int currentPage, int totalPages, int pageSize, List list) { 17 | this.totalItems = totalItems; 18 | this.currentPage = currentPage; 19 | this.totalPages = totalPages; 20 | this.pageSize = pageSize; 21 | this.list = list; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | /target 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | 35 | ### other files ### 36 | src/main/resources/application.properties 37 | *.key 38 | *.pem 39 | src/main/resources/application.yml 40 | *.env 41 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/JwtConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import io.jsonwebtoken.JwtParser; 4 | import io.jsonwebtoken.Jwts; 5 | import java.security.interfaces.RSAPublicKey; 6 | import lombok.Data; 7 | import org.springframework.beans.factory.annotation.Value; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.context.annotation.Configuration; 10 | 11 | @Data 12 | @Configuration 13 | public class JwtConfig { 14 | @Value("${jwt.secret.publicKey}") 15 | private RSAPublicKey publicKey; 16 | 17 | @Bean 18 | public JwtParser parser() { 19 | return Jwts.parserBuilder().setSigningKey(publicKey).build(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/AsyncConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import java.util.concurrent.Executor; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.scheduling.annotation.EnableAsync; 7 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 8 | 9 | @EnableAsync 10 | @Configuration 11 | public class AsyncConfig { 12 | @Bean("asyncExecutor") 13 | public Executor executor() { 14 | ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); 15 | executor.setCorePoolSize(5); 16 | return new ThreadPoolTaskExecutor(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/request/ChannelRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.request; 2 | 3 | import com.joejoe2.chat.validation.constraint.UUID; 4 | import io.swagger.v3.oas.annotations.Parameter; 5 | import jakarta.validation.constraints.NotNull; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Data 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class ChannelRequest { 16 | @Parameter(description = "id of target channel") 17 | @UUID(message = "invalid channel id !") 18 | @NotNull(message = "channelId is missing !") 19 | private String channelId; 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/request/CreatePrivateChannelRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.request; 2 | 3 | import com.joejoe2.chat.validation.constraint.UUID; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import jakarta.validation.constraints.NotNull; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Data 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class CreatePrivateChannelRequest { 16 | @Schema(description = "id of target user") 17 | @UUID(message = "invalid user id") 18 | @NotNull(message = "targetUserId is missing !") 19 | String targetUserId; 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/message/SliceOfMessage.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.message; 2 | 3 | import com.joejoe2.chat.data.SliceList; 4 | import java.util.List; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @NoArgsConstructor 9 | @Data 10 | public class SliceOfMessage { 11 | private int currentPage; 12 | private int pageSize; 13 | private boolean hasNext; 14 | private List messages; 15 | 16 | public SliceOfMessage(SliceList sliceList) { 17 | this.currentPage = sliceList.getCurrentPage(); 18 | this.pageSize = sliceList.getPageSize(); 19 | this.hasNext = sliceList.isHasNext(); 20 | this.messages = sliceList.getList(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/utils/SseUtil.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.utils; 2 | 3 | import java.io.IOException; 4 | import java.util.List; 5 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 6 | 7 | public class SseUtil { 8 | public static void addSseCallbacks(SseEmitter sseEmitter, Runnable runnable) { 9 | sseEmitter.onCompletion(runnable); 10 | } 11 | 12 | public static void sendConnectEvent(SseEmitter sseEmitter) { 13 | try { 14 | sseEmitter.send("[]"); 15 | } catch (IOException e) { 16 | e.printStackTrace(); 17 | } 18 | } 19 | 20 | public static void sendMessageEvent(SseEmitter sseEmitter, Object data) throws IOException { 21 | sseEmitter.send(List.of(data)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/UserPublicProfile.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data; 2 | 3 | import com.joejoe2.chat.models.User; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | import lombok.extern.jackson.Jacksonized; 10 | 11 | @Builder 12 | @Jacksonized 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | @Data 16 | public class UserPublicProfile { 17 | @Schema(description = "user id") 18 | String id; 19 | 20 | @Schema(description = "username") 21 | String username; 22 | 23 | public UserPublicProfile(User user) { 24 | this.id = user.getId().toString(); 25 | this.username = user.getUserName(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/utils/IPUtils.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.utils; 2 | 3 | import org.springframework.web.context.request.RequestAttributes; 4 | import org.springframework.web.context.request.RequestContextHolder; 5 | 6 | public class IPUtils { 7 | private static final String REQUEST_IP_ATTRIBUTE = "REQUEST_IP"; 8 | 9 | public static void setRequestIP(String ip) { 10 | RequestContextHolder.currentRequestAttributes() 11 | .setAttribute(REQUEST_IP_ATTRIBUTE, ip, RequestAttributes.SCOPE_REQUEST); 12 | } 13 | 14 | public static String getRequestIP() { 15 | return (String) 16 | RequestContextHolder.currentRequestAttributes() 17 | .getAttribute(REQUEST_IP_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/PublicKeyConverter.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import java.io.ByteArrayInputStream; 4 | import java.security.PublicKey; 5 | import lombok.SneakyThrows; 6 | import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; 7 | import org.springframework.core.convert.converter.Converter; 8 | import org.springframework.security.converter.RsaKeyConverters; 9 | import org.springframework.stereotype.Component; 10 | 11 | @Component 12 | @ConfigurationPropertiesBinding 13 | public class PublicKeyConverter implements Converter { 14 | @SneakyThrows 15 | @Override 16 | public PublicKey convert(String from) { 17 | return RsaKeyConverters.x509().convert(new ByteArrayInputStream(from.getBytes())); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/validation/validator/UUIDValidator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.validation.validator; 2 | 3 | import com.joejoe2.chat.exception.ValidationError; 4 | import java.util.UUID; 5 | 6 | public class UUIDValidator implements Validator { 7 | private static final UUIDValidator instance = new UUIDValidator(); 8 | 9 | private UUIDValidator() {} 10 | 11 | public static UUIDValidator getInstance() { 12 | return instance; 13 | } 14 | 15 | @Override 16 | public UUID validate(String data) throws ValidationError { 17 | if (data == null) throw new ValidationError("uuid can not be null !"); 18 | 19 | try { 20 | return UUID.fromString(data); 21 | } catch (Exception e) { 22 | throw new ValidationError(e.getMessage()); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/NatsConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import io.nats.client.Connection; 4 | import io.nats.client.Dispatcher; 5 | import io.nats.client.Nats; 6 | import java.io.IOException; 7 | import org.springframework.beans.factory.annotation.Value; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.context.annotation.Configuration; 10 | 11 | @Configuration 12 | public class NatsConfig { 13 | @Value("${nats.url}") 14 | private String natsUrl; 15 | 16 | @Bean 17 | Connection natsConnection() throws IOException, InterruptedException { 18 | return Nats.connect(natsUrl); 19 | } 20 | 21 | @Bean 22 | Dispatcher dispatcher(Connection natsConnection) { 23 | return natsConnection.createDispatcher((msg) -> {}); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/PageRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data; 2 | 3 | import io.swagger.v3.oas.annotations.Parameter; 4 | import jakarta.validation.constraints.Min; 5 | import jakarta.validation.constraints.NotNull; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Data 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class PageRequest { 16 | @Parameter(description = "must >= 0") 17 | @Min(value = 0, message = "page must >= 0 !") 18 | @NotNull(message = "page is missing !") 19 | private Integer page; 20 | 21 | @Parameter(description = "must >= 1") 22 | @Min(value = 1, message = "page must >= 1 !") 23 | @NotNull(message = "size is missing !") 24 | private Integer size; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/SliceOfGroupChannel.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel; 2 | 3 | import com.joejoe2.chat.data.SliceList; 4 | import com.joejoe2.chat.data.channel.profile.GroupChannelProfile; 5 | import java.util.List; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | @Data 10 | @NoArgsConstructor 11 | public class SliceOfGroupChannel { 12 | private int currentPage; 13 | private int pageSize; 14 | private boolean hasNext; 15 | private List channels; 16 | 17 | public SliceOfGroupChannel(SliceList sliceList) { 18 | this.currentPage = sliceList.getCurrentPage(); 19 | this.pageSize = sliceList.getPageSize(); 20 | this.hasNext = sliceList.isHasNext(); 21 | this.channels = sliceList.getList(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/SliceOfPrivateChannel.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel; 2 | 3 | import com.joejoe2.chat.data.SliceList; 4 | import com.joejoe2.chat.data.channel.profile.PrivateChannelProfile; 5 | import java.util.List; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | @Data 10 | @NoArgsConstructor 11 | public class SliceOfPrivateChannel { 12 | private int currentPage; 13 | private int pageSize; 14 | private boolean hasNext; 15 | private List channels; 16 | 17 | public SliceOfPrivateChannel(SliceList sliceList) { 18 | this.currentPage = sliceList.getCurrentPage(); 19 | this.pageSize = sliceList.getPageSize(); 20 | this.hasNext = sliceList.isHasNext(); 21 | this.channels = sliceList.getList(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/request/ChannelBlockRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.request; 2 | 3 | import com.joejoe2.chat.validation.constraint.UUID; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import jakarta.validation.constraints.NotNull; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Data 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class ChannelBlockRequest { 16 | @Schema(description = "id of target channel") 17 | @UUID(message = "invalid channel id !") 18 | @NotNull(message = "channelId is missing !") 19 | private String channelId; 20 | 21 | @Schema(description = "block state of target channel") 22 | @NotNull(message = "isBlocked is missing !") 23 | private Boolean isBlocked; 24 | } 25 | -------------------------------------------------------------------------------- /src/main/resources/application-dev.properties: -------------------------------------------------------------------------------- 1 | server.port=8081 2 | # db related settings 3 | spring.datasource.url=jdbc:postgresql://localhost:5432/spring-chat 4 | spring.datasource.username=postgres 5 | spring.datasource.password=pa55ward 6 | spring.datasource.driver-class-name=org.postgresql.Driver 7 | spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect 8 | spring.jpa.properties.hibernate.hbm2ddl.auto=none 9 | spring.jpa.open-in-view=false 10 | spring.liquibase.enabled=true 11 | spring.liquibase.change-log=classpath:/db/changelog/db.changelog-master.yaml 12 | # redis related settings 13 | spring.data.redis.host=localhost 14 | spring.data.redis.port=6379 15 | nats.url=nats://localhost:4222 16 | # metrics 17 | management.server.port=8099 18 | management.endpoints.web.exposure.include=metrics,prometheus 19 | management.endpoint.health.probes.enabled=true 20 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/message/request/PublishMessageRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.message.request; 2 | 3 | import com.joejoe2.chat.validation.constraint.Message; 4 | import com.joejoe2.chat.validation.constraint.UUID; 5 | import io.swagger.v3.oas.annotations.media.Schema; 6 | import jakarta.validation.constraints.NotNull; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Builder; 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | 12 | @Data 13 | @Builder 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | public class PublishMessageRequest { 17 | @Schema(description = "id of target channel") 18 | @UUID(message = "invalid channel id !") 19 | @NotNull(message = "channelId is missing !") 20 | private String channelId; 21 | 22 | @Schema(description = "content of message") 23 | @Message 24 | private String message; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/request/ChannelUserRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.request; 2 | 3 | import com.joejoe2.chat.validation.constraint.UUID; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import jakarta.validation.constraints.NotNull; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Data 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class ChannelUserRequest { 16 | @Schema(description = "id of target channel") 17 | @UUID(message = "invalid channel id !") 18 | @NotNull(message = "channelId is missing !") 19 | private String channelId; 20 | 21 | @Schema(description = "id of target user") 22 | @UUID(message = "invalid user id") 23 | @NotNull(message = "channelId is missing !") 24 | String targetUserId; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/PageOfChannel.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel; 2 | 3 | import com.joejoe2.chat.data.PageList; 4 | import com.joejoe2.chat.data.channel.profile.PublicChannelProfile; 5 | import java.util.List; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | @Data 10 | @NoArgsConstructor 11 | public class PageOfChannel { 12 | private long totalItems; 13 | private int currentPage; 14 | private int totalPages; 15 | private int pageSize; 16 | private List channels; 17 | 18 | public PageOfChannel(PageList pageList) { 19 | this.totalItems = pageList.getTotalItems(); 20 | this.currentPage = pageList.getCurrentPage(); 21 | this.totalPages = pageList.getTotalPages(); 22 | this.pageSize = pageList.getPageSize(); 23 | this.channels = pageList.getList(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/request/SubscribeChannelRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.request; 2 | 3 | import com.joejoe2.chat.validation.constraint.UUID; 4 | import io.swagger.v3.oas.annotations.Parameter; 5 | import jakarta.validation.constraints.NotEmpty; 6 | import jakarta.validation.constraints.NotNull; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Builder; 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | 12 | @Data 13 | @Builder 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | public class SubscribeChannelRequest { 17 | @Parameter(description = "access token in query") 18 | @NotEmpty 19 | private String access_token; 20 | 21 | @Parameter(description = "id of target channel") 22 | @UUID(message = "invalid channel id !") 23 | @NotNull(message = "channelId is missing !") 24 | private String channelId; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/PageRequestWithSince.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data; 2 | 3 | import io.swagger.v3.oas.annotations.Parameter; 4 | import jakarta.validation.Valid; 5 | import jakarta.validation.constraints.NotNull; 6 | import java.time.Instant; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Builder; 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | import org.springframework.format.annotation.DateTimeFormat; 12 | 13 | @Data 14 | @Builder 15 | @NoArgsConstructor 16 | @AllArgsConstructor 17 | public class PageRequestWithSince { 18 | @Parameter(description = "page parameters") 19 | @Valid 20 | @NotNull(message = "page request is missing !") 21 | private PageRequest pageRequest; 22 | 23 | @Parameter(description = "since in UTC") 24 | @NotNull(message = "invalid since format !") 25 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) 26 | Instant since; 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/validation/constraint/UUID.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.validation.constraint; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import jakarta.validation.Constraint; 6 | import jakarta.validation.Payload; 7 | import jakarta.validation.ReportAsSingleViolation; 8 | import jakarta.validation.constraints.Pattern; 9 | import java.lang.annotation.ElementType; 10 | import java.lang.annotation.Retention; 11 | import java.lang.annotation.Target; 12 | 13 | @Target({ElementType.FIELD, ElementType.PARAMETER}) 14 | @Constraint(validatedBy = {}) 15 | @Retention(RUNTIME) 16 | @Pattern(regexp = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") 17 | @ReportAsSingleViolation 18 | public @interface UUID { 19 | String message() default "invalid uuid !"; 20 | 21 | Class[] groups() default {}; 22 | 23 | Class[] payload() default {}; 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/message/PublicMessageService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.message; 2 | 3 | import com.joejoe2.chat.data.PageRequest; 4 | import com.joejoe2.chat.data.SliceList; 5 | import com.joejoe2.chat.data.message.PublicMessageDto; 6 | import com.joejoe2.chat.exception.ChannelDoesNotExist; 7 | import com.joejoe2.chat.exception.UserDoesNotExist; 8 | import java.time.Instant; 9 | 10 | public interface PublicMessageService { 11 | PublicMessageDto createMessage(String fromUserId, String channelId, String message) 12 | throws UserDoesNotExist, ChannelDoesNotExist; 13 | 14 | void deliverMessage(PublicMessageDto message); 15 | 16 | SliceList getAllMessages(String channelId, PageRequest pageRequest) 17 | throws ChannelDoesNotExist; 18 | 19 | SliceList getAllMessages( 20 | String channelId, Instant since, PageRequest pageRequest) throws ChannelDoesNotExist; 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/PrivateKeyConverter.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import java.security.KeyFactory; 4 | import java.security.PrivateKey; 5 | import java.security.spec.PKCS8EncodedKeySpec; 6 | import java.util.Base64; 7 | import lombok.SneakyThrows; 8 | import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; 9 | import org.springframework.core.convert.converter.Converter; 10 | import org.springframework.stereotype.Component; 11 | 12 | @Component 13 | @ConfigurationPropertiesBinding 14 | public class PrivateKeyConverter implements Converter { 15 | @SneakyThrows 16 | @Override 17 | public PrivateKey convert(String from) { 18 | byte[] bytes = Base64.getDecoder().decode(from.getBytes()); 19 | PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes); 20 | KeyFactory factory = KeyFactory.getInstance("RSA"); 21 | return factory.generatePrivate(spec); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/request/ChannelPageRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.request; 2 | 3 | import com.joejoe2.chat.data.PageRequest; 4 | import com.joejoe2.chat.validation.constraint.UUID; 5 | import io.swagger.v3.oas.annotations.Parameter; 6 | import jakarta.validation.Valid; 7 | import jakarta.validation.constraints.NotNull; 8 | import lombok.AllArgsConstructor; 9 | import lombok.Builder; 10 | import lombok.Data; 11 | import lombok.NoArgsConstructor; 12 | 13 | @Data 14 | @Builder 15 | @NoArgsConstructor 16 | @AllArgsConstructor 17 | public class ChannelPageRequest { 18 | @Parameter(description = "id of target channel") 19 | @UUID(message = "invalid channel id !") 20 | @NotNull(message = "channelId is missing !") 21 | private String channelId; 22 | 23 | @Parameter(description = "page parameters") 24 | @Valid 25 | @NotNull(message = "page request is missing !") 26 | private PageRequest pageRequest; 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/redis/RedisServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.redis; 2 | 3 | import java.time.Duration; 4 | import java.util.Optional; 5 | import org.springframework.data.redis.core.StringRedisTemplate; 6 | import org.springframework.stereotype.Service; 7 | 8 | @Service 9 | public class RedisServiceImpl implements RedisService { 10 | private final StringRedisTemplate redisTemplate; 11 | 12 | public RedisServiceImpl(StringRedisTemplate redisTemplate) { 13 | this.redisTemplate = redisTemplate; 14 | } 15 | 16 | @Override 17 | public void set(String key, String value, Duration duration) { 18 | redisTemplate.opsForValue().setIfAbsent(key, value, duration); 19 | } 20 | 21 | @Override 22 | public Optional get(String key) { 23 | return Optional.ofNullable(redisTemplate.opsForValue().get(key)); 24 | } 25 | 26 | @Override 27 | public boolean has(String key) { 28 | return redisTemplate.hasKey(key); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/message/GroupMessageDto.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.message; 2 | 3 | import com.joejoe2.chat.data.UserPublicProfile; 4 | import com.joejoe2.chat.models.GroupMessage; 5 | import com.joejoe2.chat.utils.TimeUtil; 6 | import lombok.NoArgsConstructor; 7 | import lombok.extern.jackson.Jacksonized; 8 | 9 | @Jacksonized 10 | @NoArgsConstructor 11 | public class GroupMessageDto extends MessageDto { 12 | public GroupMessageDto(GroupMessage message) { 13 | super( 14 | message.getVersion(), 15 | message.getId(), 16 | message.getChannel().getId(), 17 | message.getMessageType(), 18 | UserPublicProfile.builder() 19 | .id(message.getFrom().getId().toString()) 20 | .username(message.getFrom().getUserName()) 21 | .build(), 22 | message.getContent(), 23 | TimeUtil.roundToMicro(message.getCreateAt()).toString(), 24 | TimeUtil.roundToMicro(message.getUpdateAt()).toString()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/message/PublicMessageDto.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.message; 2 | 3 | import com.joejoe2.chat.data.UserPublicProfile; 4 | import com.joejoe2.chat.models.PublicMessage; 5 | import com.joejoe2.chat.utils.TimeUtil; 6 | import lombok.NoArgsConstructor; 7 | import lombok.extern.jackson.Jacksonized; 8 | 9 | @Jacksonized 10 | @NoArgsConstructor 11 | public class PublicMessageDto extends MessageDto { 12 | public PublicMessageDto(PublicMessage message) { 13 | super( 14 | message.getVersion(), 15 | message.getId(), 16 | message.getChannel().getId(), 17 | message.getMessageType(), 18 | UserPublicProfile.builder() 19 | .id(message.getFrom().getId().toString()) 20 | .username(message.getFrom().getUserName()) 21 | .build(), 22 | message.getContent(), 23 | TimeUtil.roundToMicro(message.getCreateAt()).toString(), 24 | TimeUtil.roundToMicro(message.getUpdateAt()).toString()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/resources/application-test.properties: -------------------------------------------------------------------------------- 1 | server.port=8081 2 | spring.datasource.url=jdbc:postgresql://localhost:5430/spring-chat 3 | spring.datasource.username=postgres 4 | spring.datasource.password=pa55ward 5 | spring.datasource.driver-class-name=org.postgresql.Driver 6 | spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect 7 | spring.jpa.properties.hibernate.hbm2ddl.auto=none 8 | spring.jpa.open-in-view=false 9 | spring.liquibase.enabled=true 10 | spring.liquibase.change-log=classpath:/db/test/changelog/db.changelog-master.yaml 11 | # do not change these 12 | spring.data.redis.host=localhost 13 | # open port 6370 instead of 6379 for test only 14 | spring.data.redis.port=6370 15 | nats.url=nats://localhost:4221 16 | # log sql 17 | logging.level.org.hibernate.SQL=DEBUG 18 | logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE 19 | # metrics 20 | management.server.port=8099 21 | management.endpoints.web.exposure.include=metrics,prometheus 22 | management.endpoint.health.probes.enabled=true 23 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/InterceptorConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import com.joejoe2.chat.interceptor.ControllerConstraintInterceptor; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.web.servlet.config.annotation.EnableWebMvc; 6 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 7 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 8 | 9 | @Configuration 10 | @EnableWebMvc 11 | public class InterceptorConfig implements WebMvcConfigurer { 12 | private final ControllerConstraintInterceptor controllerConstraintInterceptor; 13 | 14 | public InterceptorConfig(ControllerConstraintInterceptor controllerConstraintInterceptor) { 15 | this.controllerConstraintInterceptor = controllerConstraintInterceptor; 16 | } 17 | 18 | @Override 19 | public void addInterceptors(InterceptorRegistry registry) { 20 | registry.addInterceptor(controllerConstraintInterceptor).addPathPatterns("/**"); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/request/EditBannedRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.request; 2 | 3 | import com.joejoe2.chat.validation.constraint.UUID; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import jakarta.validation.constraints.NotNull; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Data 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class EditBannedRequest { 16 | @Schema(description = "id of target channel") 17 | @UUID(message = "invalid channel id !") 18 | @NotNull(message = "channelId is missing !") 19 | private String channelId; 20 | 21 | @Schema(description = "id of target user") 22 | @UUID(message = "invalid user id") 23 | @NotNull(message = "channelId is missing !") 24 | private String targetUserId; 25 | 26 | @Schema(description = "editBanned state of target user") 27 | @NotNull(message = "isBanned is missing !") 28 | private Boolean isBanned; 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/request/EditAdminRequest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.request; 2 | 3 | import com.joejoe2.chat.validation.constraint.UUID; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import jakarta.validation.constraints.NotNull; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Data 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class EditAdminRequest { 16 | @Schema(description = "id of target channel") 17 | @UUID(message = "invalid channel id !") 18 | @NotNull(message = "channelId is missing !") 19 | private String channelId; 20 | 21 | @Schema(description = "id of target user") 22 | @UUID(message = "invalid user id") 23 | @NotNull(message = "channelId is missing !") 24 | private String targetUserId; 25 | 26 | @Schema(description = "set target user to administrator or not") 27 | @NotNull(message = "isAdmin is missing !") 28 | private Boolean isAdmin; 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/validation/validator/MessageValidator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.validation.validator; 2 | 3 | import com.joejoe2.chat.exception.ValidationError; 4 | 5 | public class MessageValidator implements Validator { 6 | public static final int minLength = 1; 7 | public static final int maxLength = 4096; 8 | 9 | private static final MessageValidator instance = new MessageValidator(); 10 | 11 | private MessageValidator() {} 12 | 13 | public static MessageValidator getInstance() { 14 | return instance; 15 | } 16 | 17 | @Override 18 | public String validate(String data) throws ValidationError { 19 | String message = data; 20 | if (message == null) throw new ValidationError("message can not be null !"); 21 | message = message.trim(); 22 | 23 | if (message.length() == 0) throw new ValidationError("message can not be empty !"); 24 | if (message.length() > maxLength) 25 | throw new ValidationError("the length of message is at most " + maxLength + " !"); 26 | 27 | return message; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /env/application-caht.env.example: -------------------------------------------------------------------------------- 1 | # db related settings 2 | spring.datasource.url=jdbc:postgresql://chat-db:5432/spring-chat 3 | spring.datasource.username=postgres 4 | spring.datasource.password=root_password 5 | spring.datasource.driver-class-name=org.postgresql.Driver 6 | spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect 7 | spring.jpa.properties.hibernate.hbm2ddl.auto=none 8 | spring.jpa.open-in-view=false 9 | spring.liquibase.enabled=true 10 | 11 | # redis related settings 12 | spring.data.redis.host=redis 13 | spring.data.redis.port=6379 14 | 15 | nats.url=nats://nats:4222 16 | 17 | # jwt related settings 18 | jwt.secret.publicKey= 19 | 20 | # for nginx 21 | server.forward-headers-strategy=native 22 | server.tomcat.remote-ip-header=x-forwarded-for 23 | server.tomcat.protocol-header=x-forwarded-proto 24 | 25 | # open api 26 | springdoc.api-docs.enabled=false 27 | springdoc.swagger-ui.enabled=false 28 | 29 | # metrics 30 | management.server.port=8099 31 | management.endpoints.web.exposure.include=metrics,prometheus 32 | management.endpoint.health.probes.enabled=true 33 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/utils/WebSocketUtil.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.utils; 2 | 3 | import java.io.IOException; 4 | import org.springframework.web.socket.TextMessage; 5 | import org.springframework.web.socket.WebSocketSession; 6 | 7 | public class WebSocketUtil { 8 | public static void addFinishedCallbacks(WebSocketSession session, Runnable runnable) { 9 | session.getAttributes().put("finishedCallbacks", runnable); 10 | } 11 | 12 | public static void executeFinishedCallbacks(WebSocketSession session) { 13 | if (session.getAttributes().containsKey("finishedCallbacks")) { 14 | ((Runnable) session.getAttributes().get("finishedCallbacks")).run(); 15 | } 16 | } 17 | 18 | public static void sendConnectMessage(WebSocketSession session) { 19 | try { 20 | session.sendMessage(new TextMessage("[]")); 21 | } catch (IOException e) { 22 | e.printStackTrace(); 23 | executeFinishedCallbacks(session); 24 | } 25 | } 26 | 27 | public static void sendMessage(WebSocketSession session, TextMessage message) throws IOException { 28 | session.sendMessage(message); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 joejoe2 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/utils/ChannelSubject.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.utils; 2 | 3 | public class ChannelSubject { 4 | private static final String PUBLIC_CHANNEL = "chat.channel.public."; 5 | private static final String PRIVATE_CHANNEL = "chat.channel.private.user."; 6 | 7 | private static final String GROUP_CHANNEL = "chat.channel.GROUP.user."; 8 | 9 | public static String publicChannelSubject(String channelId) { 10 | return PUBLIC_CHANNEL + channelId; 11 | } 12 | 13 | public static String publicChannelOfSubject(String subject) { 14 | return subject.replace(PUBLIC_CHANNEL, ""); 15 | } 16 | 17 | public static String privateChannelSubject(String userId) { 18 | return PRIVATE_CHANNEL + userId; 19 | } 20 | 21 | public static String privateChannelUserOfSubject(String subject) { 22 | return subject.replace(PRIVATE_CHANNEL, ""); 23 | } 24 | 25 | public static String groupChannelSubject(String userId) { 26 | return GROUP_CHANNEL + userId; 27 | } 28 | 29 | public static String groupChannelUserOfSubject(String subject) { 30 | return subject.replace(GROUP_CHANNEL, ""); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/models/GroupInvitationKey.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.models; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.Embeddable; 5 | import java.io.Serializable; 6 | import java.util.Objects; 7 | import java.util.UUID; 8 | import lombok.Builder; 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | 12 | @Embeddable 13 | @Data 14 | @NoArgsConstructor 15 | @Builder 16 | public class GroupInvitationKey implements Serializable { 17 | @Column(name = "user_id", nullable = false) 18 | UUID userId; 19 | 20 | @Column(name = "group_channel_id", nullable = false) 21 | UUID channelId; 22 | 23 | public GroupInvitationKey(UUID userId, UUID channelId) { 24 | this.userId = userId; 25 | this.channelId = channelId; 26 | } 27 | 28 | @Override 29 | public boolean equals(Object o) { 30 | if (this == o) return true; 31 | if (!(o instanceof GroupInvitationKey that)) return false; 32 | return userId.equals(that.userId) && channelId.equals(that.channelId); 33 | } 34 | 35 | @Override 36 | public int hashCode() { 37 | return Objects.hash(userId, channelId); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: workflow 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'src/**' 9 | - 'pom.xml' 10 | - 'Dockerfile' 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 # downloads a copy of repository 18 | 19 | - name: Setup JDK 17 20 | uses: actions/setup-java@v2 21 | with: 22 | java-version: '17' 23 | distribution: 'adopt' 24 | 25 | - name: Test 26 | run: mvn test 27 | 28 | - name: Build jar 29 | run: mvn package -Dmaven.test.skip=true 30 | 31 | - name: Login to Docker Hub 32 | uses: docker/login-action@v1 33 | with: 34 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 35 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 36 | 37 | - name: Build and Push to Docker Hub 38 | uses: docker/build-push-action@v2 39 | with: 40 | context: . 41 | file: ./Dockerfile 42 | push: true 43 | tags: ${{ secrets.DOCKER_HUB_USERNAME }}/spring-chat:latest 44 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/validation/constraint/Message.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.validation.constraint; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import com.joejoe2.chat.validation.validator.MessageValidator; 6 | import jakarta.validation.Constraint; 7 | import jakarta.validation.Payload; 8 | import jakarta.validation.constraints.NotBlank; 9 | import jakarta.validation.constraints.Size; 10 | import java.lang.annotation.ElementType; 11 | import java.lang.annotation.Retention; 12 | import java.lang.annotation.Target; 13 | 14 | @Target(ElementType.FIELD) 15 | @Constraint(validatedBy = {}) 16 | @Size( 17 | min = MessageValidator.minLength, 18 | message = "length of message is at least " + MessageValidator.minLength + " !") 19 | @Size( 20 | max = MessageValidator.maxLength, 21 | message = "length of message name is at most " + MessageValidator.maxLength + " !") 22 | @NotBlank(message = "message cannot be empty !") 23 | @Retention(RUNTIME) 24 | public @interface Message { 25 | String message() default ""; 26 | 27 | Class[] groups() default {}; 28 | 29 | Class[] payload() default {}; 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/jwt/JwtServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.jwt; 2 | 3 | import com.joejoe2.chat.config.JwtConfig; 4 | import com.joejoe2.chat.data.UserDetail; 5 | import com.joejoe2.chat.exception.InvalidTokenException; 6 | import com.joejoe2.chat.service.redis.RedisService; 7 | import com.joejoe2.chat.utils.JwtUtil; 8 | import io.jsonwebtoken.JwtParser; 9 | import org.springframework.stereotype.Service; 10 | 11 | @Service 12 | public class JwtServiceImpl implements JwtService { 13 | private final RedisService redisService; 14 | private final JwtParser jwtParser; 15 | 16 | public JwtServiceImpl(JwtConfig jwtConfig, RedisService redisService, JwtParser jwtParser) { 17 | this.redisService = redisService; 18 | this.jwtParser = jwtParser; 19 | } 20 | 21 | @Override 22 | public UserDetail getUserDetailFromAccessToken(String token) throws InvalidTokenException { 23 | return JwtUtil.extractUserDetailFromAccessToken(jwtParser, token); 24 | } 25 | 26 | @Override 27 | public boolean isAccessTokenInBlackList(String accessPlainToken) { 28 | return redisService.has("revoked_access_token:{" + accessPlainToken + "}"); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/message/GroupMessageService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.message; 2 | 3 | import com.joejoe2.chat.data.PageRequest; 4 | import com.joejoe2.chat.data.SliceList; 5 | import com.joejoe2.chat.data.message.GroupMessageDto; 6 | import com.joejoe2.chat.exception.ChannelDoesNotExist; 7 | import com.joejoe2.chat.exception.InvalidOperation; 8 | import com.joejoe2.chat.exception.UserDoesNotExist; 9 | import java.time.Instant; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | public interface GroupMessageService { 13 | GroupMessageDto createMessage(String fromUserId, String channelId, String message) 14 | throws UserDoesNotExist, ChannelDoesNotExist, InvalidOperation; 15 | 16 | void deliverMessage(GroupMessageDto message); 17 | 18 | SliceList getAllMessages( 19 | String userId, String channelId, Instant since, PageRequest pageRequest) 20 | throws UserDoesNotExist, ChannelDoesNotExist, InvalidOperation; 21 | 22 | @Transactional(readOnly = true) 23 | SliceList getInvitations(String userId, Instant since, PageRequest pageRequest) 24 | throws UserDoesNotExist; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/message/PrivateMessageDto.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.message; 2 | 3 | import com.joejoe2.chat.data.UserPublicProfile; 4 | import com.joejoe2.chat.models.PrivateMessage; 5 | import com.joejoe2.chat.utils.TimeUtil; 6 | import lombok.NoArgsConstructor; 7 | import lombok.extern.jackson.Jacksonized; 8 | 9 | @Jacksonized 10 | @NoArgsConstructor 11 | public class PrivateMessageDto extends MessageDto { 12 | public PrivateMessageDto(PrivateMessage message) { 13 | super( 14 | message.getVersion(), 15 | message.getId(), 16 | message.getChannel().getId(), 17 | message.getMessageType(), 18 | UserPublicProfile.builder() 19 | .id(message.getFrom().getId().toString()) 20 | .username(message.getFrom().getUserName()) 21 | .build(), 22 | UserPublicProfile.builder() 23 | .id(message.getFrom().getId().toString()) 24 | .username(message.getTo().getUserName()) 25 | .build(), 26 | message.getContent(), 27 | TimeUtil.roundToMicro(message.getCreateAt()).toString(), 28 | TimeUtil.roundToMicro(message.getUpdateAt()).toString()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/utils/HttpUtil.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.utils; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import java.net.URLDecoder; 5 | import java.nio.charset.StandardCharsets; 6 | import java.util.HashMap; 7 | import java.util.Map; 8 | import org.springframework.http.HttpHeaders; 9 | 10 | public class HttpUtil { 11 | public static String extractAccessToken(HttpServletRequest request) { 12 | String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); 13 | if (authHeader != null) { 14 | return authHeader.replace("Bearer ", ""); 15 | } else { 16 | return request.getParameter("access_token"); 17 | } 18 | } 19 | 20 | public static Map splitQuery(String query) { 21 | Map query_pairs = new HashMap<>(); 22 | String[] pairs = query.split("&"); 23 | for (String pair : pairs) { 24 | int idx = pair.indexOf("="); 25 | if (idx != -1) 26 | query_pairs.put( 27 | URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8), 28 | URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8)); 29 | } 30 | return query_pairs; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /doc/chat-detail/README.md: -------------------------------------------------------------------------------- 1 | # Technical details of the chat 2 | 3 | ## Chat in public channel 4 | 5 | - A public channel allows any user to chat online. 6 | - Each subscription target is channel id. 7 | - Query history messages by channel id. 8 | 9 | ![image](public-chat.png) 10 | 11 | ## Chat in private channel 12 | 13 | - Every two users can chat with each other in a private channel. 14 | - Each subscription target is user id. 15 | - Query history messages by user id 16 | 17 | ![image](private-chat.png) 18 | 19 | ## Chat in group channel 20 | 21 | - A group channel allows any members to chat online. A user should 22 | be invited to the channel first by any member, and the user should 23 | accept the invitation to become a member. 24 | - Each subscription target is user id. 25 | - Query history messages by channel id. 26 | 27 | ![image](group-chat.png) 28 | 29 | ## Block a private channel 30 | 31 | If userA block userB, 32 | 33 | - any future msgs from userB to userA is invisible, unblocked will not take these msgs back 34 | - publish msg endpoint will return 403 when userB sends msg to userA (actually do not persist/deliver msgs) 35 | - userB can still get new msgs from userA until userB block userA 36 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/chat/utils/IPUtilsTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.utils; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | 5 | import com.joejoe2.chat.TestContext; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.springframework.boot.test.context.SpringBootTest; 9 | import org.springframework.test.context.ActiveProfiles; 10 | import org.springframework.web.context.request.RequestAttributes; 11 | import org.springframework.web.context.request.RequestContextHolder; 12 | 13 | @SpringBootTest 14 | @ActiveProfiles("test") 15 | @ExtendWith(TestContext.class) 16 | class IPUtilsTest { 17 | @Test 18 | void setRequestIP() { 19 | IPUtils.setRequestIP("127.0.0.1"); 20 | assertEquals( 21 | "127.0.0.1", 22 | RequestContextHolder.currentRequestAttributes() 23 | .getAttribute("REQUEST_IP", RequestAttributes.SCOPE_REQUEST)); 24 | } 25 | 26 | @Test 27 | void getRequestIP() { 28 | RequestContextHolder.currentRequestAttributes() 29 | .setAttribute("REQUEST_IP", "127.0.0.1", RequestAttributes.SCOPE_REQUEST); 30 | assertEquals("127.0.0.1", IPUtils.getRequestIP()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/interceptor/AuthenticatedHandshakeInterceptor.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.interceptor; 2 | 3 | import com.joejoe2.chat.utils.AuthUtil; 4 | import com.joejoe2.chat.utils.HttpUtil; 5 | import java.util.Map; 6 | import org.springframework.http.server.ServerHttpRequest; 7 | import org.springframework.http.server.ServerHttpResponse; 8 | import org.springframework.stereotype.Component; 9 | import org.springframework.web.socket.WebSocketHandler; 10 | import org.springframework.web.socket.server.HandshakeInterceptor; 11 | 12 | @Component 13 | public class AuthenticatedHandshakeInterceptor implements HandshakeInterceptor { 14 | @Override 15 | public boolean beforeHandshake( 16 | ServerHttpRequest request, 17 | ServerHttpResponse response, 18 | WebSocketHandler wsHandler, 19 | Map attributes) { 20 | if (!AuthUtil.isAuthenticated()) return false; 21 | attributes.putAll(HttpUtil.splitQuery(request.getURI().getQuery())); 22 | return true; 23 | } 24 | 25 | @Override 26 | public void afterHandshake( 27 | ServerHttpRequest request, 28 | ServerHttpResponse response, 29 | WebSocketHandler wsHandler, 30 | Exception exception) {} 31 | } 32 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/2023/10/10-01-changelog.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: 1696145356872-3 4 | author: joejoe2 (generated) 5 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 6 | changes: 7 | - addColumn: 8 | columns: 9 | - column: 10 | defaultValueBoolean: false 11 | name: is_user1blocked 12 | type: BOOLEAN 13 | - column: 14 | defaultValueBoolean: false 15 | name: is_user2blocked 16 | type: BOOLEAN 17 | tableName: private_channel 18 | - changeSet: 19 | id: 1696145356872-4 20 | author: joejoe2 (generated) 21 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 22 | changes: 23 | - addNotNullConstraint: 24 | columnName: is_user1blocked 25 | tableName: private_channel 26 | - changeSet: 27 | id: 1696145356872-6 28 | author: joejoe2 (generated) 29 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 30 | changes: 31 | - addNotNullConstraint: 32 | columnName: is_user2blocked 33 | tableName: private_channel 34 | 35 | -------------------------------------------------------------------------------- /src/main/resources/db/test/changelog/2023/10/10-01-changelog.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: 1696145356872-3 4 | author: joejoe2 (generated) 5 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 6 | changes: 7 | - addColumn: 8 | columns: 9 | - column: 10 | defaultValueBoolean: false 11 | name: is_user1blocked 12 | type: BOOLEAN 13 | - column: 14 | defaultValueBoolean: false 15 | name: is_user2blocked 16 | type: BOOLEAN 17 | tableName: private_channel 18 | - changeSet: 19 | id: 1696145356872-4 20 | author: joejoe2 (generated) 21 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 22 | changes: 23 | - addNotNullConstraint: 24 | columnName: is_user1blocked 25 | tableName: private_channel 26 | - changeSet: 27 | id: 1696145356872-6 28 | author: joejoe2 (generated) 29 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 30 | changes: 31 | - addNotNullConstraint: 32 | columnName: is_user2blocked 33 | tableName: private_channel 34 | 35 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/validation/validator/PageRequestValidator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.validation.validator; 2 | 3 | import com.joejoe2.chat.data.PageRequest; 4 | import com.joejoe2.chat.exception.ValidationError; 5 | 6 | public class PageRequestValidator 7 | implements Validator { 8 | private static final PageRequestValidator instance = new PageRequestValidator(); 9 | 10 | private PageRequestValidator() {} 11 | 12 | public static PageRequestValidator getInstance() { 13 | return instance; 14 | } 15 | 16 | @Override 17 | public org.springframework.data.domain.PageRequest validate(PageRequest data) 18 | throws ValidationError { 19 | if (data == null) throw new ValidationError("page request cannot be null !"); 20 | if (data.getPage() == null) throw new ValidationError("page cannot be null !"); 21 | if (data.getSize() == null) throw new ValidationError("page cannot be null !"); 22 | if (data.getPage() < 0) throw new ValidationError("page must >=0 !"); 23 | if (data.getSize() <= 0) throw new ValidationError("size must >=1 !"); 24 | return org.springframework.data.domain.PageRequest.of(data.getPage(), data.getSize()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/request/ChannelPageRequestWithSince.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.request; 2 | 3 | import com.joejoe2.chat.data.PageRequest; 4 | import com.joejoe2.chat.validation.constraint.UUID; 5 | import io.swagger.v3.oas.annotations.Parameter; 6 | import jakarta.validation.Valid; 7 | import jakarta.validation.constraints.NotNull; 8 | import java.time.Instant; 9 | import lombok.AllArgsConstructor; 10 | import lombok.Builder; 11 | import lombok.Data; 12 | import lombok.NoArgsConstructor; 13 | import org.springframework.format.annotation.DateTimeFormat; 14 | 15 | @Data 16 | @Builder 17 | @NoArgsConstructor 18 | @AllArgsConstructor 19 | public class ChannelPageRequestWithSince { 20 | @Parameter(description = "id of target channel") 21 | @UUID(message = "invalid channel id !") 22 | @NotNull(message = "channelId is missing !") 23 | private String channelId; 24 | 25 | @Parameter(description = "page parameters") 26 | @Valid 27 | @NotNull(message = "page request is missing !") 28 | private PageRequest pageRequest; 29 | 30 | @Parameter(description = "since in UTC") 31 | @NotNull(message = "invalid since format !") 32 | @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) 33 | Instant since; 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 # downloads a copy of repository 15 | 16 | - name: Setup JDK 17 17 | uses: actions/setup-java@v2 18 | with: 19 | java-version: '17' 20 | distribution: 'adopt' 21 | 22 | - name: Test 23 | run: mvn test 24 | 25 | - name: Build jar 26 | run: mvn package -Dmaven.test.skip=true 27 | 28 | - name: Login to Docker Hub 29 | uses: docker/login-action@v1 30 | with: 31 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 32 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 33 | 34 | - name: Write release version 35 | run: | 36 | VERSION=${GITHUB_REF_NAME#v} 37 | echo Version: $VERSION 38 | echo "VERSION=$VERSION" >> $GITHUB_ENV 39 | 40 | - name: Build and Push to Docker Hub 41 | uses: docker/build-push-action@v2 42 | with: 43 | context: . 44 | file: ./Dockerfile 45 | push: true 46 | tags: ${{ secrets.DOCKER_HUB_USERNAME }}/spring-chat:${{ env.VERSION }} 47 | -------------------------------------------------------------------------------- /.jpb/jpb-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/repository/message/PublicMessageRepository.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.repository.message; 2 | 3 | import com.joejoe2.chat.models.PublicMessage; 4 | import java.time.Instant; 5 | import java.util.Optional; 6 | import java.util.UUID; 7 | import org.springframework.data.domain.Pageable; 8 | import org.springframework.data.domain.Slice; 9 | import org.springframework.data.jpa.repository.JpaRepository; 10 | import org.springframework.data.jpa.repository.Query; 11 | import org.springframework.data.repository.query.Param; 12 | 13 | public interface PublicMessageRepository extends JpaRepository { 14 | Optional findById(UUID id); 15 | 16 | @Query( 17 | nativeQuery = true, 18 | value = 19 | "SELECT * FROM public_message WHERE channel_id = :channel " 20 | + "AND update_at >= :since ORDER BY update_at DESC") 21 | Slice findAllByChannelSince( 22 | @Param("channel") UUID channel, @Param("since") Instant since, Pageable pageable); 23 | 24 | @Query( 25 | nativeQuery = true, 26 | value = 27 | "SELECT * FROM public_message WHERE channel_id = :channel " + "ORDER BY update_at DESC") 28 | Slice findAllByChannel(@Param("channel") UUID channelId, Pageable pageable); 29 | 30 | void deleteByCreateAtLessThan(Instant dateTime); 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/validation/constraint/PublicChannelName.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.validation.constraint; 2 | 3 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 4 | 5 | import com.joejoe2.chat.validation.validator.ChannelNameValidator; 6 | import jakarta.validation.Constraint; 7 | import jakarta.validation.Payload; 8 | import jakarta.validation.constraints.NotEmpty; 9 | import jakarta.validation.constraints.Pattern; 10 | import jakarta.validation.constraints.Size; 11 | import java.lang.annotation.ElementType; 12 | import java.lang.annotation.Retention; 13 | import java.lang.annotation.Target; 14 | 15 | @Target(ElementType.FIELD) 16 | @Constraint(validatedBy = {}) 17 | @Retention(RUNTIME) 18 | @Size( 19 | min = ChannelNameValidator.minLength, 20 | message = "length of channel name is at least " + ChannelNameValidator.minLength + " !") 21 | @Size( 22 | max = ChannelNameValidator.maxLength, 23 | message = "length of channel name is at most " + ChannelNameValidator.maxLength + " !") 24 | @NotEmpty(message = "channel name cannot be empty !") 25 | @Pattern(regexp = ChannelNameValidator.REGEX, message = ChannelNameValidator.NOT_MATCH_MSG) 26 | public @interface PublicChannelName { 27 | String message() default ChannelNameValidator.NOT_MATCH_MSG; 28 | 29 | Class[] groups() default {}; 30 | 31 | Class[] payload() default {}; 32 | } 33 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/chat/service/redis/RedisServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.redis; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertTrue; 4 | 5 | import com.joejoe2.chat.TestContext; 6 | import java.time.Duration; 7 | import java.util.concurrent.TimeUnit; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.context.SpringBootTest; 12 | import org.springframework.data.redis.core.StringRedisTemplate; 13 | import org.springframework.test.context.ActiveProfiles; 14 | 15 | @SpringBootTest 16 | @ActiveProfiles("test") 17 | @ExtendWith(TestContext.class) 18 | class RedisServiceTest { 19 | @Autowired RedisService redisService; 20 | @Autowired StringRedisTemplate redisTemplate; 21 | 22 | @Test 23 | void set() { 24 | redisService.set("key1", "test", Duration.ofSeconds(30)); 25 | assertTrue(redisTemplate.hasKey("key1")); 26 | assert redisTemplate.getExpire("key1", TimeUnit.SECONDS) < 30; 27 | } 28 | 29 | @Test 30 | void get() { 31 | redisTemplate.opsForValue().set("key2", "test", Duration.ofSeconds(30)); 32 | redisService.get("key2").get().equals("test"); 33 | } 34 | 35 | @Test 36 | void has() { 37 | redisTemplate.opsForValue().set("key3", "test", Duration.ofSeconds(30)); 38 | assert redisService.has("key3"); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/chat/utils/HttpUtilTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.utils; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | 5 | import jakarta.servlet.http.HttpServletRequest; 6 | import java.util.HashMap; 7 | import java.util.Map; 8 | import org.junit.jupiter.api.Test; 9 | import org.mockito.Mockito; 10 | import org.springframework.http.HttpHeaders; 11 | 12 | class HttpUtilTest { 13 | 14 | @Test 15 | void extractAccessToken() { 16 | HttpServletRequest request = Mockito.mock(HttpServletRequest.class); 17 | Mockito.doReturn("Bearer token").when(request).getHeader(HttpHeaders.AUTHORIZATION); 18 | assertEquals("token", HttpUtil.extractAccessToken(request)); 19 | Mockito.doReturn("token").when(request).getParameter("access_token"); 20 | assertEquals("token", HttpUtil.extractAccessToken(request)); 21 | } 22 | 23 | @Test 24 | void splitQuery() { 25 | Map params = new HashMap<>(); 26 | params.put("k1", "1"); 27 | params.put("k2", "str"); 28 | params.put("k3", "45799962"); 29 | 30 | String query = "arr=[1, 2, 3]"; 31 | for (Map.Entry param : params.entrySet()) { 32 | query += "&" + param.getKey() + "=" + param.getValue(); 33 | } 34 | params.put("arr", "[1, 2, 3]"); 35 | // test invalid query 36 | query += "&"; 37 | query += "&invalid"; 38 | 39 | assertEquals(params, HttpUtil.splitQuery(query)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/RedisConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import java.util.Map; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.data.redis.connection.RedisConnectionFactory; 7 | import org.springframework.data.redis.core.RedisTemplate; 8 | import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; 9 | import org.springframework.data.redis.serializer.StringRedisSerializer; 10 | 11 | @Configuration 12 | public class RedisConfig { 13 | private final RedisConnectionFactory redisConnectionFactory; 14 | 15 | public RedisConfig(RedisConnectionFactory redisConnectionFactory) { 16 | this.redisConnectionFactory = redisConnectionFactory; 17 | } 18 | 19 | @Bean 20 | public RedisTemplate> hashRedisTemplate() { 21 | RedisTemplate> redisTemplate = new RedisTemplate<>(); 22 | redisTemplate.setConnectionFactory(redisConnectionFactory); 23 | redisTemplate.setKeySerializer(new StringRedisSerializer()); 24 | redisTemplate.setHashKeySerializer(new StringRedisSerializer()); 25 | redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); 26 | redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); 27 | redisTemplate.afterPropertiesSet(); 28 | return redisTemplate; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/message/PrivateMessageService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.message; 2 | 3 | import com.joejoe2.chat.data.PageRequest; 4 | import com.joejoe2.chat.data.SliceList; 5 | import com.joejoe2.chat.data.message.PrivateMessageDto; 6 | import com.joejoe2.chat.exception.BlockedException; 7 | import com.joejoe2.chat.exception.ChannelDoesNotExist; 8 | import com.joejoe2.chat.exception.InvalidOperation; 9 | import com.joejoe2.chat.exception.UserDoesNotExist; 10 | import java.time.Instant; 11 | 12 | public interface PrivateMessageService { 13 | PrivateMessageDto createMessage(String fromUserId, String channelId, String message) 14 | throws UserDoesNotExist, ChannelDoesNotExist, InvalidOperation, BlockedException; 15 | 16 | void deliverMessage(PrivateMessageDto message); 17 | 18 | SliceList getAllMessages(String userId, PageRequest pageRequest) 19 | throws UserDoesNotExist; 20 | 21 | SliceList getAllMessages( 22 | String userId, String channelId, PageRequest pageRequest) 23 | throws UserDoesNotExist, ChannelDoesNotExist, InvalidOperation; 24 | 25 | SliceList getAllMessages(String userId, Instant since, PageRequest pageRequest) 26 | throws UserDoesNotExist; 27 | 28 | SliceList getAllMessages( 29 | String userId, String channelId, Instant since, PageRequest pageRequest) 30 | throws UserDoesNotExist, ChannelDoesNotExist, InvalidOperation; 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/controller/constraint/checker/ControllerAuthConstraintChecker.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.controller.constraint.checker; 2 | 3 | import com.joejoe2.chat.controller.constraint.auth.AuthenticatedApi; 4 | import com.joejoe2.chat.exception.ControllerConstraintViolation; 5 | import com.joejoe2.chat.utils.AuthUtil; 6 | import java.lang.annotation.Annotation; 7 | import java.lang.reflect.Method; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Component 11 | public class ControllerAuthConstraintChecker { 12 | public void checkWithMethod(Method method) throws ControllerConstraintViolation { 13 | checkAuthenticatedApiConstraint(method); 14 | } 15 | 16 | private static void checkAuthenticatedApiConstraint(Method method) 17 | throws ControllerConstraintViolation { 18 | AuthenticatedApi constraint = method.getAnnotation(AuthenticatedApi.class); 19 | if (constraint != null) { 20 | if (!AuthUtil.isAuthenticated()) 21 | throw new ControllerConstraintViolation( 22 | constraint.rejectStatus(), constraint.rejectMessage()); 23 | } 24 | 25 | for (Annotation annotation : method.getAnnotations()) { 26 | constraint = annotation.annotationType().getAnnotation(AuthenticatedApi.class); 27 | if (constraint != null) { 28 | if (!AuthUtil.isAuthenticated()) 29 | throw new ControllerConstraintViolation( 30 | constraint.rejectStatus(), constraint.rejectMessage()); 31 | else break; 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/models/User.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.models; 2 | 3 | import jakarta.persistence.*; 4 | import java.util.Objects; 5 | import java.util.Set; 6 | import java.util.UUID; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Builder; 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | import org.hibernate.annotations.BatchSize; 12 | 13 | @Data 14 | @Builder 15 | @AllArgsConstructor 16 | @NoArgsConstructor 17 | @Entity 18 | @BatchSize(size = 128) // for many to one 19 | @Table(name = "account_user") 20 | public class User { 21 | @Id 22 | @Column(unique = true, updatable = false, nullable = false) 23 | private UUID id; 24 | 25 | @Column(unique = true, length = 32, nullable = false) 26 | private String userName; 27 | 28 | @ManyToMany(mappedBy = "members") 29 | private Set privateChannels; 30 | 31 | @ManyToMany(mappedBy = "blockedBy") 32 | private Set blockedPrivateChannels; 33 | 34 | @ManyToMany(mappedBy = "members") 35 | private Set groupChannels; 36 | 37 | @OneToMany(mappedBy = "user") 38 | private Set invitations; 39 | 40 | @Override 41 | public boolean equals(Object o) { 42 | if (this == o) return true; 43 | if (!(o instanceof User user)) return false; 44 | return Objects.equals(id, user.id) && Objects.equals(userName, user.userName); 45 | } 46 | 47 | @Override 48 | public int hashCode() { 49 | return Objects.hash(id, userName); 50 | } 51 | 52 | @Override 53 | public String toString() { 54 | return "User{" + "id=" + id + ", userName='" + userName + '\'' + '}'; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/profile/GroupChannelProfile.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.profile; 2 | 3 | import com.joejoe2.chat.data.UserPublicProfile; 4 | import com.joejoe2.chat.models.GroupChannel; 5 | import com.joejoe2.chat.utils.TimeUtil; 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import java.util.List; 8 | import java.util.Objects; 9 | import java.util.stream.Collectors; 10 | import lombok.Data; 11 | import lombok.NoArgsConstructor; 12 | 13 | @Data 14 | @NoArgsConstructor 15 | public class GroupChannelProfile { 16 | @Schema(description = "id of the channel") 17 | private String id; 18 | 19 | @Schema(description = "name of the channel") 20 | private String name; 21 | 22 | @Schema(description = "members of the channel") 23 | private List members; 24 | 25 | private String createAt; 26 | private String updateAt; 27 | 28 | public GroupChannelProfile(GroupChannel channel) { 29 | this.id = channel.getId().toString(); 30 | this.name = channel.getName(); 31 | this.members = 32 | channel.getMembers().stream().map(UserPublicProfile::new).collect(Collectors.toList()); 33 | this.createAt = TimeUtil.roundToMicro(channel.getCreateAt()).toString(); 34 | this.updateAt = TimeUtil.roundToMicro(channel.getUpdateAt()).toString(); 35 | } 36 | 37 | @Override 38 | public boolean equals(Object o) { 39 | if (this == o) return true; 40 | if (!(o instanceof GroupChannelProfile that)) return false; 41 | return id.equals(that.id) && members.equals(that.members); 42 | } 43 | 44 | @Override 45 | public int hashCode() { 46 | return Objects.hash(id, members); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/models/PublicChannel.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.models; 2 | 3 | import jakarta.persistence.*; 4 | import java.time.Instant; 5 | import java.util.List; 6 | import java.util.Objects; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | import org.hibernate.annotations.BatchSize; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @Entity 14 | @BatchSize(size = 128) 15 | @Table(name = "public_channel") 16 | public class PublicChannel extends TimeStampBase { 17 | @Version 18 | @Column(nullable = false, columnDefinition = "TIMESTAMP DEFAULT now()") 19 | private Instant version; 20 | 21 | @Column(unique = true, updatable = false, nullable = false, length = 128) 22 | String name; 23 | 24 | @OneToMany(cascade = CascadeType.ALL, mappedBy = "channel", orphanRemoval = true) 25 | @BatchSize(size = 128) 26 | List messages; 27 | 28 | public PublicChannel(String name) { 29 | this.name = name; 30 | } 31 | 32 | @Override 33 | public boolean equals(Object o) { 34 | if (this == o) return true; 35 | if (!(o instanceof PublicChannel)) return false; 36 | PublicChannel channel = (PublicChannel) o; 37 | return Objects.equals(id, channel.id) && Objects.equals(name, channel.name); 38 | } 39 | 40 | @Override 41 | public int hashCode() { 42 | return Objects.hash(id, name); 43 | } 44 | 45 | @Override 46 | public String toString() { 47 | return "PublicChannel{" 48 | + "id=" 49 | + id 50 | + ", name='" 51 | + name 52 | + '\'' 53 | + ", createAt=" 54 | + createAt 55 | + ", updateAt=" 56 | + updateAt 57 | + '}'; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/validation/validator/ChannelNameValidator.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.validation.validator; 2 | 3 | import com.joejoe2.chat.exception.ValidationError; 4 | import java.util.regex.Pattern; 5 | 6 | public class ChannelNameValidator implements Validator { 7 | public static final int minLength = 3; 8 | public static final int maxLength = 64; 9 | public static final String REGEX = "[a-zA-Z0-9]+"; 10 | public static final String NOT_MATCH_MSG = "channelName can only contain a-z, A-Z, and 0-9 !"; 11 | 12 | private static final Pattern pattern = Pattern.compile(REGEX); 13 | 14 | private static final ChannelNameValidator instance = new ChannelNameValidator(); 15 | 16 | private ChannelNameValidator() {} 17 | 18 | public static ChannelNameValidator getInstance() { 19 | return instance; 20 | } 21 | 22 | @Override 23 | public String validate(String data) throws ValidationError { 24 | String channelName = data; 25 | if (channelName == null) throw new ValidationError("password can not be null !"); 26 | channelName = channelName.trim(); 27 | 28 | if (channelName.length() == 0) throw new ValidationError("channelName can not be empty !"); 29 | if (channelName.length() < minLength) 30 | throw new ValidationError( 31 | "the length of channelName is at least " + ChannelNameValidator.minLength + " !"); 32 | if (channelName.length() > maxLength) 33 | throw new ValidationError( 34 | "the length of channelName is at most " + ChannelNameValidator.maxLength + " !"); 35 | if (!pattern.matcher(channelName).matches()) throw new ValidationError(NOT_MATCH_MSG); 36 | 37 | return channelName; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/repository/message/GroupMessageRepository.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.repository.message; 2 | 3 | import com.joejoe2.chat.models.GroupChannel; 4 | import com.joejoe2.chat.models.GroupMessage; 5 | import com.joejoe2.chat.models.User; 6 | import java.time.Instant; 7 | import java.util.Optional; 8 | import java.util.UUID; 9 | import org.springframework.data.domain.Pageable; 10 | import org.springframework.data.domain.Slice; 11 | import org.springframework.data.jpa.repository.JpaRepository; 12 | import org.springframework.data.jpa.repository.Query; 13 | import org.springframework.data.repository.query.Param; 14 | 15 | public interface GroupMessageRepository extends JpaRepository { 16 | Optional findById(UUID id); 17 | 18 | @Query( 19 | "SELECT msg FROM GroupMessage msg WHERE msg.channel = :channel AND msg.updateAt >= :since" 20 | + " ORDER BY msg.updateAt DESC") 21 | Slice findAllByChannelSince( 22 | @Param("channel") GroupChannel channel, @Param("since") Instant since, Pageable pageable); 23 | 24 | @Query("SELECT msg FROM GroupMessage msg WHERE msg.channel = :channel ORDER BY msg.updateAt DESC") 25 | Slice findAllByChannel(@Param("channel") GroupChannel channel, Pageable pageable); 26 | 27 | @Query( 28 | "SELECT invitation.invitationMessage from GroupInvitation invitation " 29 | + "where invitation.user = :user " 30 | + "and invitation.createAt >= :since ORDER BY invitation.createAt DESC") 31 | Slice findInvitations( 32 | @Param("user") User user, @Param("since") Instant since, Pageable pageable); 33 | 34 | void deleteByCreateAtLessThan(Instant dateTime); 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/CacheConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import java.time.Duration; 4 | import org.springframework.cache.CacheManager; 5 | import org.springframework.cache.annotation.EnableCaching; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.data.redis.cache.RedisCacheConfiguration; 9 | import org.springframework.data.redis.cache.RedisCacheManager; 10 | import org.springframework.data.redis.connection.RedisConnectionFactory; 11 | import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; 12 | import org.springframework.data.redis.serializer.RedisSerializationContext; 13 | import org.springframework.data.redis.serializer.StringRedisSerializer; 14 | 15 | @Configuration 16 | @EnableCaching 17 | public class CacheConfig { 18 | @Bean 19 | public CacheManager cacheManager(RedisConnectionFactory lettuceConnectionFactory) { 20 | RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig(); 21 | defaultCacheConfig = 22 | defaultCacheConfig 23 | .entryTtl(Duration.ofMinutes(10)) 24 | .serializeKeysWith( 25 | RedisSerializationContext.SerializationPair.fromSerializer( 26 | new StringRedisSerializer())) 27 | .serializeValuesWith( 28 | RedisSerializationContext.SerializationPair.fromSerializer( 29 | new GenericJackson2JsonRedisSerializer())) 30 | .disableCachingNullValues(); 31 | 32 | return RedisCacheManager.builder(lettuceConnectionFactory) 33 | .cacheDefaults(defaultCacheConfig) 34 | .build(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/channel/profile/PrivateChannelProfile.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.channel.profile; 2 | 3 | import com.joejoe2.chat.data.UserPublicProfile; 4 | import com.joejoe2.chat.models.PrivateChannel; 5 | import com.joejoe2.chat.models.User; 6 | import com.joejoe2.chat.utils.TimeUtil; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import java.util.List; 9 | import java.util.Objects; 10 | import java.util.stream.Collectors; 11 | import lombok.Data; 12 | import lombok.NoArgsConstructor; 13 | 14 | @Data 15 | @NoArgsConstructor 16 | public class PrivateChannelProfile { 17 | @Schema(description = "id of the channel") 18 | private String id; 19 | 20 | @Schema(description = "members of the channel") 21 | private List members; 22 | 23 | @Schema(description = "block state of the channel") 24 | private Boolean isBlocked; 25 | 26 | private String createAt; 27 | private String updateAt; 28 | 29 | public PrivateChannelProfile(PrivateChannel channel, User currentUser) { 30 | this.id = channel.getId().toString(); 31 | this.members = 32 | channel.getMembers().stream().map(UserPublicProfile::new).collect(Collectors.toList()); 33 | this.isBlocked = channel.isBlocked(channel.anotherMember(currentUser)); 34 | this.createAt = TimeUtil.roundToMicro(channel.getCreateAt()).toString(); 35 | this.updateAt = TimeUtil.roundToMicro(channel.getUpdateAt()).toString(); 36 | } 37 | 38 | @Override 39 | public boolean equals(Object o) { 40 | if (this == o) return true; 41 | if (!(o instanceof PrivateChannelProfile that)) return false; 42 | return id.equals(that.id) && members.equals(that.members); 43 | } 44 | 45 | @Override 46 | public int hashCode() { 47 | return Objects.hash(id, members); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/controller/GroupChannelWSHandler.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.controller; 2 | 3 | import com.joejoe2.chat.exception.UserDoesNotExist; 4 | import com.joejoe2.chat.service.channel.GroupChannelService; 5 | import com.joejoe2.chat.utils.AuthUtil; 6 | import com.joejoe2.chat.utils.WebSocketUtil; 7 | import org.springframework.stereotype.Component; 8 | import org.springframework.web.socket.CloseStatus; 9 | import org.springframework.web.socket.WebSocketSession; 10 | import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; 11 | import org.springframework.web.socket.handler.TextWebSocketHandler; 12 | 13 | @Component 14 | public class GroupChannelWSHandler extends TextWebSocketHandler { 15 | private final GroupChannelService channelService; 16 | 17 | public GroupChannelWSHandler(GroupChannelService channelService) { 18 | this.channelService = channelService; 19 | } 20 | 21 | @Override 22 | public void afterConnectionEstablished(WebSocketSession session) throws Exception { 23 | WebSocketSession webSocketSession = 24 | new ConcurrentWebSocketSessionDecorator(session, 5000, 1024 * 512); 25 | try { 26 | channelService.subscribe( 27 | webSocketSession, AuthUtil.currentUserDetail(webSocketSession).getId()); 28 | } catch (IllegalArgumentException | UserDoesNotExist e) { 29 | webSocketSession.close(CloseStatus.BAD_DATA); 30 | } catch (Exception e) { 31 | webSocketSession.close(CloseStatus.SERVER_ERROR); 32 | } 33 | } 34 | 35 | @Override 36 | public void handleTransportError(WebSocketSession session, Throwable exception) { 37 | exception.printStackTrace(); 38 | } 39 | 40 | @Override 41 | public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { 42 | WebSocketUtil.executeFinishedCallbacks(session); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/controller/PrivateChannelWSHandler.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.controller; 2 | 3 | import com.joejoe2.chat.exception.UserDoesNotExist; 4 | import com.joejoe2.chat.service.channel.PrivateChannelService; 5 | import com.joejoe2.chat.utils.AuthUtil; 6 | import com.joejoe2.chat.utils.WebSocketUtil; 7 | import org.springframework.stereotype.Component; 8 | import org.springframework.web.socket.CloseStatus; 9 | import org.springframework.web.socket.WebSocketSession; 10 | import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; 11 | import org.springframework.web.socket.handler.TextWebSocketHandler; 12 | 13 | @Component 14 | public class PrivateChannelWSHandler extends TextWebSocketHandler { 15 | private final PrivateChannelService channelService; 16 | 17 | public PrivateChannelWSHandler(PrivateChannelService channelService) { 18 | this.channelService = channelService; 19 | } 20 | 21 | @Override 22 | public void afterConnectionEstablished(WebSocketSession session) throws Exception { 23 | WebSocketSession webSocketSession = 24 | new ConcurrentWebSocketSessionDecorator(session, 5000, 1024 * 512); 25 | try { 26 | channelService.subscribe( 27 | webSocketSession, AuthUtil.currentUserDetail(webSocketSession).getId()); 28 | } catch (IllegalArgumentException | UserDoesNotExist e) { 29 | webSocketSession.close(CloseStatus.BAD_DATA); 30 | } catch (Exception e) { 31 | webSocketSession.close(CloseStatus.SERVER_ERROR); 32 | } 33 | } 34 | 35 | @Override 36 | public void handleTransportError(WebSocketSession session, Throwable exception) { 37 | exception.printStackTrace(); 38 | } 39 | 40 | @Override 41 | public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { 42 | WebSocketUtil.executeFinishedCallbacks(session); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/controller/PublicChannelWSHandler.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.controller; 2 | 3 | import com.joejoe2.chat.exception.ChannelDoesNotExist; 4 | import com.joejoe2.chat.service.channel.PublicChannelService; 5 | import com.joejoe2.chat.utils.WebSocketUtil; 6 | import org.springframework.stereotype.Component; 7 | import org.springframework.web.socket.CloseStatus; 8 | import org.springframework.web.socket.WebSocketSession; 9 | import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; 10 | import org.springframework.web.socket.handler.TextWebSocketHandler; 11 | 12 | @Component 13 | public class PublicChannelWSHandler extends TextWebSocketHandler { 14 | private final PublicChannelService channelService; 15 | 16 | public PublicChannelWSHandler(PublicChannelService channelService) { 17 | this.channelService = channelService; 18 | } 19 | 20 | @Override 21 | public void afterConnectionEstablished(WebSocketSession session) throws Exception { 22 | WebSocketSession webSocketSession = 23 | new ConcurrentWebSocketSessionDecorator(session, 5000, 1024 * 512); 24 | try { 25 | String channelId = (String) webSocketSession.getAttributes().getOrDefault("channelId", ""); 26 | channelService.subscribe(webSocketSession, channelId); 27 | } catch (IllegalArgumentException | ChannelDoesNotExist e) { 28 | webSocketSession.close(CloseStatus.BAD_DATA); 29 | } catch (Exception e) { 30 | webSocketSession.close(CloseStatus.SERVER_ERROR); 31 | } 32 | } 33 | 34 | @Override 35 | public void handleTransportError(WebSocketSession session, Throwable exception) { 36 | exception.printStackTrace(); 37 | } 38 | 39 | @Override 40 | public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { 41 | WebSocketUtil.executeFinishedCallbacks(session); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /env/application.env.example: -------------------------------------------------------------------------------- 1 | # db related settings 2 | spring.datasource.url=jdbc:postgresql://db:5432/spring 3 | spring.datasource.username=postgres 4 | spring.datasource.password=root_password 5 | spring.datasource.driver-class-name=org.postgresql.Driver 6 | spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect 7 | spring.jpa.properties.hibernate.hbm2ddl.auto=none 8 | spring.jpa.open-in-view=false 9 | spring.liquibase.enabled=true 10 | 11 | # redis related settings 12 | spring.data.redis.host=redis 13 | spring.data.redis.port=6379 14 | 15 | # create default admin account 16 | default.admin.username=admin 17 | default.admin.password=pa55ward 18 | default.admin.email=admin@email.com 19 | 20 | # jwt related settings 21 | jwt.issuer=joejoe2.com 22 | jwt.secret.privateKey= 23 | jwt.secret.publicKey= 24 | # in seconds 25 | jwt.access.token.lifetime=900 26 | jwt.refresh.token.lifetime=1800 27 | 28 | # set allow host (frontend) 29 | allow.host=http://localhost:8888 30 | # set reset password url 31 | reset.password.url=http://localhost:8888/resetPassword?token= 32 | 33 | # login max attempt settings 34 | login.maxAttempts=5 35 | # in seconds 36 | login.attempts.coolTime=900 37 | 38 | # mail sender 39 | spring.mail.host=smtp.gmail.com 40 | spring.mail.port=587 41 | spring.mail.username=test@gmail.com 42 | spring.mail.password=pa55ward 43 | spring.mail.properties.mail.smtp.auth=true 44 | spring.mail.properties.mail.smtp.starttls.enable=true 45 | 46 | # for nginx 47 | server.forward-headers-strategy=native 48 | server.tomcat.remote-ip-header=x-forwarded-for 49 | server.tomcat.protocol-header=x-forwarded-proto 50 | 51 | # jobrunr 52 | org.jobrunr.background-job-server.enabled=true 53 | org.jobrunr.dashboard.enabled=false 54 | org.jobrunr.database.type=sql 55 | init.recurrent-job=true 56 | 57 | # open api 58 | springdoc.api-docs.enabled=false 59 | springdoc.swagger-ui.enabled=false 60 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/channel/PublicChannelService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.channel; 2 | 3 | import com.joejoe2.chat.data.PageList; 4 | import com.joejoe2.chat.data.PageRequest; 5 | import com.joejoe2.chat.data.channel.profile.PublicChannelProfile; 6 | import com.joejoe2.chat.exception.AlreadyExist; 7 | import com.joejoe2.chat.exception.ChannelDoesNotExist; 8 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 9 | import org.springframework.web.socket.WebSocketSession; 10 | 11 | public interface PublicChannelService { 12 | /** 13 | * subscribe to target public channel using Server Sent Event(SSE) 14 | * 15 | * @param channelId target channel id 16 | * @return SseEmitter 17 | * @throws ChannelDoesNotExist 18 | */ 19 | SseEmitter subscribe(String channelId) throws ChannelDoesNotExist; 20 | 21 | /** 22 | * subscribe to target public channel using WebSocket 23 | * 24 | * @param session WebSocket session 25 | * @param channelId target channel id 26 | * @throws ChannelDoesNotExist 27 | */ 28 | void subscribe(WebSocketSession session, String channelId) throws ChannelDoesNotExist; 29 | 30 | /** 31 | * create a new public channel 32 | * 33 | * @param channelName 34 | * @return created public channel 35 | * @throws AlreadyExist 36 | */ 37 | PublicChannelProfile createChannel(String channelName) throws AlreadyExist; 38 | 39 | /** 40 | * get all public channels with page 41 | * 42 | * @param pageRequest 43 | * @return 44 | */ 45 | PageList getAllChannels(PageRequest pageRequest); 46 | 47 | /** 48 | * get profile of target channel 49 | * 50 | * @param channelId id of target channel 51 | * @return profile of target channel 52 | * @throws ChannelDoesNotExist 53 | */ 54 | PublicChannelProfile getChannelProfile(String channelId) throws ChannelDoesNotExist; 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/UserDetail.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data; 2 | 3 | import com.joejoe2.chat.models.User; 4 | import java.util.ArrayList; 5 | import java.util.Collection; 6 | import java.util.Objects; 7 | import org.springframework.security.core.GrantedAuthority; 8 | import org.springframework.security.core.userdetails.UserDetails; 9 | 10 | public class UserDetail implements UserDetails { 11 | private String id; 12 | private String username; 13 | 14 | public UserDetail(User user) { 15 | this.id = user.getId().toString(); 16 | this.username = user.getUserName(); 17 | } 18 | 19 | public UserDetail(String id, String username) { 20 | this.id = id; 21 | this.username = username; 22 | } 23 | 24 | @Override 25 | public boolean equals(Object o) { 26 | if (this == o) return true; 27 | if (!(o instanceof UserDetail)) return false; 28 | UserDetail that = (UserDetail) o; 29 | return id.equals(that.id) && username.equals(that.username); 30 | } 31 | 32 | @Override 33 | public int hashCode() { 34 | return Objects.hash(id, username); 35 | } 36 | 37 | @Override 38 | public String toString() { 39 | return "UserDetail{" + "id='" + id + '\'' + ", username='" + username + '\'' + '}'; 40 | } 41 | 42 | public String getId() { 43 | return this.id; 44 | } 45 | 46 | @Override 47 | public Collection getAuthorities() { 48 | return new ArrayList<>(); 49 | } 50 | 51 | @Override 52 | public String getPassword() { 53 | return ""; 54 | } 55 | 56 | @Override 57 | public String getUsername() { 58 | return username; 59 | } 60 | 61 | @Override 62 | public boolean isAccountNonExpired() { 63 | return true; 64 | } 65 | 66 | @Override 67 | public boolean isAccountNonLocked() { 68 | return true; 69 | } 70 | 71 | @Override 72 | public boolean isCredentialsNonExpired() { 73 | return true; 74 | } 75 | 76 | @Override 77 | public boolean isEnabled() { 78 | return true; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/repository/message/PrivateMessageRepository.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.repository.message; 2 | 3 | import com.joejoe2.chat.models.PrivateMessage; 4 | import java.time.Instant; 5 | import java.util.Optional; 6 | import java.util.UUID; 7 | import org.springframework.data.domain.Pageable; 8 | import org.springframework.data.domain.Slice; 9 | import org.springframework.data.jpa.repository.JpaRepository; 10 | import org.springframework.data.jpa.repository.Query; 11 | import org.springframework.data.repository.query.Param; 12 | 13 | public interface PrivateMessageRepository extends JpaRepository { 14 | Optional findById(UUID id); 15 | 16 | @Query( 17 | nativeQuery = true, 18 | value = 19 | "SELECT * FROM private_message WHERE channel_id = :channel " 20 | + "AND update_at >= :since ORDER BY update_at DESC") 21 | Slice findAllByChannelSince( 22 | @Param("channel") UUID channelId, @Param("since") Instant since, Pageable pageable); 23 | 24 | @Query( 25 | nativeQuery = true, 26 | value = 27 | "SELECT * FROM private_message WHERE channel_id = :channel " + "ORDER BY update_at DESC") 28 | Slice findAllByChannel(@Param("channel") UUID channelId, Pageable pageable); 29 | 30 | @Query( 31 | nativeQuery = true, 32 | value = 33 | "SELECT * FROM private_message WHERE to_id = :user OR from_id = :user " 34 | + "AND update_at >= :since ORDER BY update_at DESC") 35 | Slice findAllByUserSince( 36 | @Param("user") UUID userId, @Param("since") Instant since, Pageable pageable); 37 | 38 | @Query( 39 | nativeQuery = true, 40 | value = 41 | "SELECT * FROM private_message WHERE to_id = :user OR from_id = :user " 42 | + "ORDER BY update_at DESC") 43 | Slice findAllByUser(@Param("user") UUID userId, Pageable pageable); 44 | 45 | void deleteByCreateAtLessThan(Instant dateTime); 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/StringTrimConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import com.fasterxml.jackson.core.JsonParser; 4 | import com.fasterxml.jackson.databind.DeserializationContext; 5 | import com.fasterxml.jackson.databind.deser.std.StdScalarDeserializer; 6 | import java.io.IOException; 7 | import org.springframework.beans.propertyeditors.StringTrimmerEditor; 8 | import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; 12 | import org.springframework.util.StringUtils; 13 | import org.springframework.web.bind.WebDataBinder; 14 | import org.springframework.web.bind.annotation.ControllerAdvice; 15 | import org.springframework.web.bind.annotation.InitBinder; 16 | 17 | @Configuration 18 | public class StringTrimConfig { 19 | 20 | @ControllerAdvice 21 | public static class ControllerStringParamTrimConfig { 22 | @InitBinder 23 | public void initBinder(WebDataBinder binder) { 24 | StringTrimmerEditor propertyEditor = new StringTrimmerEditor(false); 25 | binder.registerCustomEditor(String.class, propertyEditor); 26 | } 27 | } 28 | 29 | @Bean 30 | public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() { 31 | return new Jackson2ObjectMapperBuilderCustomizer() { 32 | @Override 33 | public void customize(Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder) { 34 | jacksonObjectMapperBuilder.deserializerByType( 35 | String.class, 36 | new StdScalarDeserializer(String.class) { 37 | @Override 38 | public String deserialize(JsonParser jsonParser, DeserializationContext ctx) 39 | throws IOException { 40 | return StringUtils.trimWhitespace(jsonParser.getValueAsString()); 41 | } 42 | }); 43 | } 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/filter/JwtAuthenticationFilter.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.filter; 2 | 3 | import com.joejoe2.chat.data.UserDetail; 4 | import com.joejoe2.chat.exception.InvalidTokenException; 5 | import com.joejoe2.chat.service.jwt.JwtService; 6 | import com.joejoe2.chat.service.user.UserService; 7 | import com.joejoe2.chat.utils.AuthUtil; 8 | import com.joejoe2.chat.utils.HttpUtil; 9 | import jakarta.servlet.FilterChain; 10 | import jakarta.servlet.ServletException; 11 | import jakarta.servlet.http.HttpServletRequest; 12 | import jakarta.servlet.http.HttpServletResponse; 13 | import java.io.IOException; 14 | import org.springframework.stereotype.Component; 15 | import org.springframework.web.filter.OncePerRequestFilter; 16 | 17 | @Component 18 | public class JwtAuthenticationFilter extends OncePerRequestFilter { 19 | private final JwtService jwtService; 20 | private final UserService userService; 21 | 22 | public JwtAuthenticationFilter(JwtService jwtService, UserService userService) { 23 | this.jwtService = jwtService; 24 | this.userService = userService; 25 | } 26 | 27 | @Override 28 | protected void doFilterInternal( 29 | HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) 30 | throws ServletException, IOException { 31 | try { 32 | String accessToken = HttpUtil.extractAccessToken(request); 33 | if (accessToken != null) { 34 | if (jwtService.isAccessTokenInBlackList(accessToken)) 35 | throw new InvalidTokenException("access token has been revoked !"); 36 | UserDetail userDetail = jwtService.getUserDetailFromAccessToken(accessToken); 37 | userService.createUserIfAbsent(userDetail); 38 | AuthUtil.setCurrentUserDetail(userDetail); 39 | } 40 | } catch (InvalidTokenException e) { 41 | response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); 42 | return; 43 | } 44 | filterChain.doFilter(request, response); 45 | } 46 | 47 | @Override 48 | protected boolean shouldNotFilterAsyncDispatch() { 49 | return false; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/WebSocketConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import com.joejoe2.chat.controller.GroupChannelWSHandler; 4 | import com.joejoe2.chat.controller.PrivateChannelWSHandler; 5 | import com.joejoe2.chat.controller.PublicChannelWSHandler; 6 | import com.joejoe2.chat.interceptor.AuthenticatedHandshakeInterceptor; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.web.socket.config.annotation.EnableWebSocket; 9 | import org.springframework.web.socket.config.annotation.WebSocketConfigurer; 10 | import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; 11 | 12 | @Configuration 13 | @EnableWebSocket 14 | public class WebSocketConfig implements WebSocketConfigurer { 15 | private final PublicChannelWSHandler publicChannelWSHandler; 16 | private final PrivateChannelWSHandler privateChannelWSHandler; 17 | private final GroupChannelWSHandler groupChannelWSHandler; 18 | private final AuthenticatedHandshakeInterceptor authenticatedHandshakeInterceptor; 19 | 20 | public WebSocketConfig( 21 | PublicChannelWSHandler publicChannelWSHandler, 22 | PrivateChannelWSHandler privateChannelWSHandler, 23 | GroupChannelWSHandler groupChannelWSHandler, 24 | AuthenticatedHandshakeInterceptor authenticatedHandshakeInterceptor) { 25 | this.publicChannelWSHandler = publicChannelWSHandler; 26 | this.privateChannelWSHandler = privateChannelWSHandler; 27 | this.groupChannelWSHandler = groupChannelWSHandler; 28 | this.authenticatedHandshakeInterceptor = authenticatedHandshakeInterceptor; 29 | } 30 | 31 | @Override 32 | public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { 33 | registry 34 | .addHandler(publicChannelWSHandler, "/ws/channel/public/subscribe") 35 | .addHandler(privateChannelWSHandler, "/ws/channel/private/subscribe") 36 | .addHandler(groupChannelWSHandler, "/ws/channel/group/subscribe") 37 | .addInterceptors(authenticatedHandshakeInterceptor) 38 | .setAllowedOrigins("*"); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/repository/channel/GroupChannelRepository.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.repository.channel; 2 | 3 | import com.joejoe2.chat.models.GroupChannel; 4 | import com.joejoe2.chat.models.User; 5 | import java.time.Instant; 6 | import java.util.List; 7 | import java.util.Optional; 8 | import java.util.UUID; 9 | import org.springframework.cache.annotation.Cacheable; 10 | import org.springframework.data.domain.Pageable; 11 | import org.springframework.data.domain.Slice; 12 | import org.springframework.data.jpa.repository.JpaRepository; 13 | import org.springframework.data.jpa.repository.Query; 14 | import org.springframework.data.repository.query.Param; 15 | 16 | public interface GroupChannelRepository extends JpaRepository { 17 | Optional findById(UUID id); 18 | 19 | @Query( 20 | "SELECT DISTINCT ch from User u " 21 | + "join u.groupChannels ch where u = :user " 22 | + "and ch.updateAt >= :since ORDER BY ch.updateAt DESC") 23 | List findByMembersContainingUserByUpdateAtDesc( 24 | @Param("user") User user, @Param("since") Instant since); 25 | 26 | default List findByIsUserInMembers(User user, Instant since) { 27 | return findByMembersContainingUserByUpdateAtDesc(user, since); 28 | } 29 | 30 | @Query( 31 | "SELECT DISTINCT ch from User u " 32 | + "join u.groupChannels ch where u = :user " 33 | + "and ch.updateAt >= :since ORDER BY ch.updateAt DESC") 34 | Slice findByMembersContainingUserByUpdateAtDesc( 35 | @Param("user") User user, @Param("since") Instant since, Pageable pageable); 36 | 37 | default Slice findByIsUserInMembers(User user, Instant since, Pageable pageable) { 38 | return findByMembersContainingUserByUpdateAtDesc(user, since, pageable); 39 | } 40 | 41 | @Cacheable(value = "GroupChannelMembers", key = "'GroupChannelMembers:{'+ #id.toString() +'}'") 42 | @Query("SELECT u.id from GroupChannel ch join ch.members u where ch.id = :id") 43 | List getMembersIdByChannel(@Param("id") UUID id); 44 | } 45 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | redis: 5 | image: redis:6.2.7-alpine 6 | restart: always 7 | volumes: 8 | - ./redis/data:/data 9 | networks: 10 | - spring-net 11 | 12 | db: 13 | image: postgres:15.1 14 | restart: always 15 | environment: 16 | POSTGRES_PASSWORD: root_password # please change it 17 | POSTGRES_DB: spring 18 | volumes: 19 | - ./postgresql/data:/var/lib/postgresql/data 20 | networks: 21 | - spring-net 22 | 23 | chat-db: 24 | image: postgres:15.1 25 | restart: always 26 | environment: 27 | POSTGRES_PASSWORD: root_password # please change it 28 | POSTGRES_DB: spring-chat 29 | volumes: 30 | - ./postgresql/chat-data:/var/lib/postgresql/data 31 | networks: 32 | - spring-net 33 | 34 | nats: 35 | image: nats:2.8 36 | restart: on-failure 37 | networks: 38 | - spring-net 39 | 40 | web: 41 | image: joejoe2/spring-jwt-template:latest 42 | restart: always 43 | command: "bash ./wait-for-it.sh -t 0 db:5432 -- bash start.sh" 44 | env_file: 45 | - env/application.env 46 | networks: 47 | - spring-net 48 | depends_on: 49 | - redis 50 | - db 51 | - nats 52 | 53 | chat: 54 | image: joejoe2/spring-chat:latest 55 | restart: always 56 | command: "bash ./wait-for-it.sh -t 0 chat-db:5432 -- bash start.sh" 57 | deploy: 58 | mode: replicated 59 | replicas: 2 60 | env_file: 61 | - ./env/application-chat.env 62 | networks: 63 | - spring-net 64 | depends_on: 65 | - redis 66 | - chat-db 67 | 68 | nginx: 69 | image: jonasal/nginx-certbot:latest 70 | restart: unless-stopped 71 | env_file: 72 | - ./nginx/nginx-certbot.env 73 | ports: 74 | - 80:80 75 | - 443:443 76 | volumes: 77 | - ./nginx/data:/etc/letsencrypt 78 | - ./nginx/user_conf.d:/etc/nginx/user_conf.d 79 | networks: 80 | - spring-net 81 | depends_on: 82 | - web 83 | - chat 84 | 85 | 86 | networks: 87 | spring-net: 88 | 89 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/user/UserService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.user; 2 | 3 | import com.joejoe2.chat.data.UserDetail; 4 | import com.joejoe2.chat.exception.UserDoesNotExist; 5 | import com.joejoe2.chat.models.User; 6 | import com.joejoe2.chat.repository.user.UserRepository; 7 | import com.joejoe2.chat.validation.validator.UUIDValidator; 8 | import java.util.UUID; 9 | import org.springframework.security.core.userdetails.UserDetails; 10 | import org.springframework.security.core.userdetails.UserDetailsService; 11 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 12 | import org.springframework.stereotype.Service; 13 | import org.springframework.transaction.annotation.Transactional; 14 | 15 | @Service 16 | public class UserService implements UserDetailsService { 17 | private final UserRepository userRepository; 18 | private final UUIDValidator uuidValidator = UUIDValidator.getInstance(); 19 | 20 | public UserService(UserRepository userRepository) { 21 | this.userRepository = userRepository; 22 | } 23 | 24 | @Transactional 25 | public User getUserById(String userId) throws UserDoesNotExist { 26 | return userRepository 27 | .findById(uuidValidator.validate(userId)) 28 | .orElseThrow( 29 | () -> new UserDoesNotExist("user with id=%s does not exist !".formatted(userId))); 30 | } 31 | 32 | @Override 33 | public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { 34 | User user = 35 | userRepository 36 | .getByUserName(username) 37 | .orElseThrow(() -> new UsernameNotFoundException("user does not exist !")); 38 | return new UserDetail(user); 39 | } 40 | 41 | public void createUserIfAbsent(UserDetail userDetail) { 42 | if (!userRepository.existsById(UUID.fromString(userDetail.getId()))) { 43 | User user = 44 | User.builder() 45 | .id(UUID.fromString(userDetail.getId())) 46 | .userName(userDetail.getUsername()) 47 | .build(); 48 | userRepository.save(user); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/models/PublicMessage.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.models; 2 | 3 | import jakarta.persistence.*; 4 | import java.time.Instant; 5 | import java.util.Objects; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | import org.hibernate.annotations.OnDelete; 11 | import org.hibernate.annotations.OnDeleteAction; 12 | 13 | @Builder 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | @Data 17 | @Entity 18 | @Table( 19 | name = "public_message", 20 | indexes = {@Index(columnList = "channel_id"), @Index(columnList = "updateAt DESC")}) 21 | public class PublicMessage extends TimeStampBase { 22 | @Version 23 | @Column(nullable = false, columnDefinition = "TIMESTAMP DEFAULT now()") 24 | private Instant version; 25 | 26 | @ManyToOne(optional = false, fetch = FetchType.LAZY) 27 | @OnDelete(action = OnDeleteAction.CASCADE) 28 | private PublicChannel channel; 29 | 30 | @Column(length = 32, nullable = false) 31 | @Enumerated(EnumType.STRING) 32 | private MessageType messageType = MessageType.MESSAGE; // code level default 33 | 34 | @ManyToOne(optional = false, fetch = FetchType.LAZY) 35 | private User from; 36 | 37 | @Column(columnDefinition = "TEXT", nullable = false) 38 | private String content; 39 | 40 | @Override 41 | public boolean equals(Object o) { 42 | if (this == o) return true; 43 | if (!(o instanceof PublicMessage)) return false; 44 | PublicMessage that = (PublicMessage) o; 45 | return Objects.equals(id, that.id); 46 | } 47 | 48 | @Override 49 | public int hashCode() { 50 | return Objects.hash(id); 51 | } 52 | 53 | @Override 54 | public String toString() { 55 | return "PublicMessage{" 56 | + "id=" 57 | + id 58 | + ", channel=" 59 | + channel 60 | + ", messageType=" 61 | + messageType 62 | + ", from=" 63 | + from 64 | + ", content='" 65 | + content 66 | + '\'' 67 | + ", createAt=" 68 | + createAt 69 | + ", updateAt=" 70 | + updateAt 71 | + '}'; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/2023/10/10-03-changelog.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: 1696680319949-3 4 | author: joejoe2 (generated) 5 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 6 | changes: 7 | - createTable: 8 | columns: 9 | - column: 10 | constraints: 11 | nullable: false 12 | primaryKey: true 13 | primaryKeyName: pk_private_channels_blockedby 14 | name: private_channel_id 15 | type: UUID 16 | - column: 17 | constraints: 18 | nullable: false 19 | primaryKey: true 20 | primaryKeyName: pk_private_channels_blockedby 21 | name: user_id 22 | type: UUID 23 | tableName: private_channels_blocked_by 24 | - changeSet: 25 | id: 1696680319949-4 26 | author: joejoe2 (generated) 27 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 28 | changes: 29 | - addForeignKeyConstraint: 30 | baseColumnNames: private_channel_id 31 | baseTableName: private_channels_blocked_by 32 | constraintName: fk_prichabloby_on_private_channel 33 | referencedColumnNames: id 34 | referencedTableName: private_channel 35 | - changeSet: 36 | id: 1696680319949-5 37 | author: joejoe2 (generated) 38 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 39 | changes: 40 | - addForeignKeyConstraint: 41 | baseColumnNames: user_id 42 | baseTableName: private_channels_blocked_by 43 | constraintName: fk_prichabloby_on_user 44 | referencedColumnNames: id 45 | referencedTableName: account_user 46 | - changeSet: 47 | id: 1696680319949-6 48 | author: joejoe2 (generated) 49 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 50 | changes: 51 | - dropColumn: 52 | columnName: is_first_user_blocked 53 | tableName: private_channel 54 | - dropColumn: 55 | columnName: is_second_user_blocked 56 | tableName: private_channel 57 | 58 | -------------------------------------------------------------------------------- /src/main/resources/db/test/changelog/2023/10/10-03-changelog.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: 1696680319949-3 4 | author: joejoe2 (generated) 5 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 6 | changes: 7 | - createTable: 8 | columns: 9 | - column: 10 | constraints: 11 | nullable: false 12 | primaryKey: true 13 | primaryKeyName: pk_private_channels_blockedby 14 | name: private_channel_id 15 | type: UUID 16 | - column: 17 | constraints: 18 | nullable: false 19 | primaryKey: true 20 | primaryKeyName: pk_private_channels_blockedby 21 | name: user_id 22 | type: UUID 23 | tableName: private_channels_blocked_by 24 | - changeSet: 25 | id: 1696680319949-4 26 | author: joejoe2 (generated) 27 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 28 | changes: 29 | - addForeignKeyConstraint: 30 | baseColumnNames: private_channel_id 31 | baseTableName: private_channels_blocked_by 32 | constraintName: fk_prichabloby_on_private_channel 33 | referencedColumnNames: id 34 | referencedTableName: private_channel 35 | - changeSet: 36 | id: 1696680319949-5 37 | author: joejoe2 (generated) 38 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 39 | changes: 40 | - addForeignKeyConstraint: 41 | baseColumnNames: user_id 42 | baseTableName: private_channels_blocked_by 43 | constraintName: fk_prichabloby_on_user 44 | referencedColumnNames: id 45 | referencedTableName: account_user 46 | - changeSet: 47 | id: 1696680319949-6 48 | author: joejoe2 (generated) 49 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 50 | changes: 51 | - dropColumn: 52 | columnName: is_first_user_blocked 53 | tableName: private_channel 54 | - dropColumn: 55 | columnName: is_second_user_blocked 56 | tableName: private_channel 57 | 58 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/models/GroupInvitation.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.models; 2 | 3 | import jakarta.persistence.*; 4 | import java.time.Instant; 5 | import java.util.Objects; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | import org.hibernate.annotations.CreationTimestamp; 9 | import org.hibernate.annotations.OnDelete; 10 | import org.hibernate.annotations.OnDeleteAction; 11 | 12 | @Entity 13 | @Table(name = "group_channels_pending_users", indexes = @Index(columnList = "createAt DESC")) 14 | @Data 15 | @NoArgsConstructor 16 | public class GroupInvitation { 17 | @EmbeddedId GroupInvitationKey key; 18 | 19 | @ManyToOne(fetch = FetchType.LAZY) 20 | @OnDelete(action = OnDeleteAction.CASCADE) 21 | @MapsId("userId") 22 | @JoinColumn(name = "user_id", nullable = false) 23 | User user; 24 | 25 | @ManyToOne(fetch = FetchType.LAZY) 26 | @OnDelete(action = OnDeleteAction.CASCADE) 27 | @MapsId("channelId") 28 | @JoinColumn(name = "group_channel_id", nullable = false) 29 | GroupChannel channel; 30 | 31 | @OneToOne(optional = false, fetch = FetchType.LAZY) 32 | @OnDelete(action = OnDeleteAction.CASCADE) // if delete invitationMessage => also delete this 33 | @JoinColumn 34 | GroupMessage invitationMessage; 35 | 36 | @CreationTimestamp Instant createAt; 37 | 38 | public GroupInvitation(User user, GroupChannel channel, GroupMessage invitationMessage) { 39 | this.user = user; 40 | this.channel = channel; 41 | this.key = new GroupInvitationKey(user.getId(), channel.getId()); 42 | if (!invitationMessage.getMessageType().equals(MessageType.INVITATION)) 43 | throw new IllegalArgumentException("message type must be INVITATION !"); 44 | this.invitationMessage = invitationMessage; 45 | } 46 | 47 | public GroupInvitation(User user, GroupChannel channel) { 48 | this.user = user; 49 | this.channel = channel; 50 | this.key = new GroupInvitationKey(user.getId(), channel.getId()); 51 | } 52 | 53 | @Override 54 | public boolean equals(Object o) { 55 | if (this == o) return true; 56 | if (!(o instanceof GroupInvitation that)) return false; 57 | return key.equals(that.key); 58 | } 59 | 60 | @Override 61 | public int hashCode() { 62 | return Objects.hash(key); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/interceptor/ControllerConstraintInterceptor.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.interceptor; 2 | 3 | import com.joejoe2.chat.controller.constraint.checker.ControllerAuthConstraintChecker; 4 | import com.joejoe2.chat.exception.ControllerConstraintViolation; 5 | import jakarta.servlet.http.HttpServletRequest; 6 | import jakarta.servlet.http.HttpServletResponse; 7 | import java.io.IOException; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import org.springframework.stereotype.Component; 11 | import org.springframework.web.method.HandlerMethod; 12 | import org.springframework.web.servlet.HandlerInterceptor; 13 | 14 | @Component 15 | public class ControllerConstraintInterceptor implements HandlerInterceptor { 16 | private final ControllerAuthConstraintChecker authConstraintChecker; 17 | 18 | private static final Logger logger = 19 | LoggerFactory.getLogger(ControllerConstraintInterceptor.class); 20 | 21 | public ControllerConstraintInterceptor(ControllerAuthConstraintChecker authConstraintChecker) { 22 | this.authConstraintChecker = authConstraintChecker; 23 | } 24 | 25 | @Override 26 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) 27 | throws Exception { 28 | try { 29 | if (handler instanceof HandlerMethod) { 30 | authConstraintChecker.checkWithMethod(((HandlerMethod) handler).getMethod()); 31 | } 32 | } catch (ControllerConstraintViolation ex) { 33 | setJsonResponse(response, ex.getRejectStatus(), ex.getRejectMessage()); 34 | return false; 35 | } catch (Exception e) { 36 | logger.error(e.getMessage()); 37 | setJsonResponse(response, 500, ""); 38 | return false; 39 | } 40 | 41 | return true; 42 | } 43 | 44 | private void setJsonResponse(HttpServletResponse response, int status, String message) { 45 | if (message != null && !message.isEmpty()) { 46 | try { 47 | response.getWriter().write("{ \"message\": \"" + message + "\"}"); 48 | } catch (IOException e) { 49 | e.printStackTrace(); 50 | } 51 | } 52 | response.setContentType("application/json"); 53 | response.setCharacterEncoding("UTF-8"); 54 | response.setStatus(status); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/utils/AuthUtil.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.utils; 2 | 3 | import com.joejoe2.chat.data.UserDetail; 4 | import org.springframework.security.authentication.AnonymousAuthenticationToken; 5 | import org.springframework.security.authentication.InternalAuthenticationServiceException; 6 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 7 | import org.springframework.security.core.Authentication; 8 | import org.springframework.security.core.AuthenticationException; 9 | import org.springframework.security.core.context.SecurityContextHolder; 10 | import org.springframework.web.socket.WebSocketSession; 11 | 12 | public class AuthUtil { 13 | public static boolean isAuthenticated() { 14 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 15 | return authentication != null && !(authentication instanceof AnonymousAuthenticationToken); 16 | } 17 | 18 | public static UserDetail currentUserDetail() throws AuthenticationException { 19 | if (!isAuthenticated()) 20 | throw new InternalAuthenticationServiceException("has not been authenticated !"); 21 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 22 | return (UserDetail) authentication.getPrincipal(); 23 | } 24 | 25 | public static boolean isAuthenticated(WebSocketSession session) { 26 | Authentication authentication = (Authentication) session.getPrincipal(); 27 | return authentication != null && !(authentication instanceof AnonymousAuthenticationToken); 28 | } 29 | 30 | public static UserDetail currentUserDetail(WebSocketSession session) 31 | throws AuthenticationException { 32 | if (!isAuthenticated(session)) 33 | throw new InternalAuthenticationServiceException("has not been authenticated !"); 34 | Authentication authentication = (Authentication) session.getPrincipal(); 35 | return (UserDetail) authentication.getPrincipal(); 36 | } 37 | 38 | public static void setCurrentUserDetail(UserDetail userDetail) { 39 | Authentication authentication = 40 | new UsernamePasswordAuthenticationToken(userDetail, null, userDetail.getAuthorities()); 41 | SecurityContextHolder.getContext().setAuthentication(authentication); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/2023/06/06-01-changelog.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: 1686111589867-3 4 | author: joejoe2 (generated) 5 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 6 | changes: 7 | - addColumn: 8 | columns: 9 | - column: 10 | name: create_at 11 | type: DATETIME 12 | - column: 13 | name: invitation_message_id 14 | type: UUID 15 | tableName: group_channels_pending_users 16 | - changeSet: 17 | id: 1686111589867-5 18 | author: joejoe2 (generated) 19 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 20 | changes: 21 | - addNotNullConstraint: 22 | columnName: invitation_message_id 23 | tableName: group_channels_pending_users 24 | - changeSet: 25 | id: 1686111589867-6 26 | author: joejoe2 (generated) 27 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 28 | changes: 29 | - createIndex: 30 | columns: 31 | - column: 32 | name: user_id 33 | - column: 34 | name: group_channel_id 35 | indexName: IX_pk_group_channels_pending_users 36 | tableName: group_channels_pending_users 37 | unique: true 38 | - changeSet: 39 | id: 1686111589867-7 40 | author: joejoe2 (generated) 41 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 42 | changes: 43 | - createIndex: 44 | columns: 45 | - column: 46 | descending: true 47 | name: create_at 48 | indexName: idx_53b921905893d33dcb72cfdf8 49 | tableName: group_channels_pending_users 50 | - changeSet: 51 | id: 1686111589867-8 52 | author: joejoe2 (generated) 53 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 54 | changes: 55 | - addForeignKeyConstraint: 56 | baseColumnNames: invitation_message_id 57 | baseTableName: group_channels_pending_users 58 | constraintName: FK_GROUP_CHANNELS_PENDING_USERS_ON_INVITATIONMESSAGE 59 | onDelete: CASCADE 60 | referencedColumnNames: id 61 | referencedTableName: group_message 62 | -------------------------------------------------------------------------------- /src/main/resources/db/test/changelog/2023/06/06-01-changelog.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: 1686111589867-3 4 | author: joejoe2 (generated) 5 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 6 | changes: 7 | - addColumn: 8 | columns: 9 | - column: 10 | name: create_at 11 | type: DATETIME 12 | - column: 13 | name: invitation_message_id 14 | type: UUID 15 | tableName: group_channels_pending_users 16 | - changeSet: 17 | id: 1686111589867-5 18 | author: joejoe2 (generated) 19 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 20 | changes: 21 | - addNotNullConstraint: 22 | columnName: invitation_message_id 23 | tableName: group_channels_pending_users 24 | - changeSet: 25 | id: 1686111589867-6 26 | author: joejoe2 (generated) 27 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 28 | changes: 29 | - createIndex: 30 | columns: 31 | - column: 32 | name: user_id 33 | - column: 34 | name: group_channel_id 35 | indexName: IX_pk_group_channels_pending_users 36 | tableName: group_channels_pending_users 37 | unique: true 38 | - changeSet: 39 | id: 1686111589867-7 40 | author: joejoe2 (generated) 41 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 42 | changes: 43 | - createIndex: 44 | columns: 45 | - column: 46 | descending: true 47 | name: create_at 48 | indexName: idx_53b921905893d33dcb72cfdf8 49 | tableName: group_channels_pending_users 50 | - changeSet: 51 | id: 1686111589867-8 52 | author: joejoe2 (generated) 53 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 54 | changes: 55 | - addForeignKeyConstraint: 56 | baseColumnNames: invitation_message_id 57 | baseTableName: group_channels_pending_users 58 | constraintName: FK_GROUP_CHANNELS_PENDING_USERS_ON_INVITATIONMESSAGE 59 | onDelete: CASCADE 60 | referencedColumnNames: id 61 | referencedTableName: group_message 62 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/nats/NatsServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.nats; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.joejoe2.chat.data.message.GroupMessageDto; 5 | import com.joejoe2.chat.data.message.PrivateMessageDto; 6 | import com.joejoe2.chat.data.message.PublicMessageDto; 7 | import io.nats.client.Connection; 8 | import io.nats.client.Dispatcher; 9 | import io.nats.client.MessageHandler; 10 | import io.nats.client.Subscription; 11 | import java.nio.charset.StandardCharsets; 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | import org.springframework.stereotype.Service; 15 | 16 | @Service 17 | public class NatsServiceImpl implements NatsService { 18 | private final Connection natsConnection; 19 | private final Dispatcher natsDispatcher; 20 | private final ObjectMapper objectMapper; 21 | 22 | private static final Logger logger = LoggerFactory.getLogger(NatsService.class); 23 | 24 | public NatsServiceImpl( 25 | Connection natsConnection, Dispatcher natsDispatcher, ObjectMapper objectMapper) { 26 | this.natsConnection = natsConnection; 27 | this.natsDispatcher = natsDispatcher; 28 | this.objectMapper = objectMapper; 29 | } 30 | 31 | public void publish(String subject, String message) { 32 | natsConnection.publish(subject, message.getBytes(StandardCharsets.UTF_8)); 33 | } 34 | 35 | @Override 36 | public void publish(String subject, PrivateMessageDto message) { 37 | try { 38 | publish(subject, objectMapper.writeValueAsString(message)); 39 | } catch (Exception e) { 40 | logger.error(e.getMessage()); 41 | } 42 | } 43 | 44 | @Override 45 | public void publish(String subject, PublicMessageDto message) { 46 | try { 47 | publish(subject, objectMapper.writeValueAsString(message)); 48 | } catch (Exception e) { 49 | logger.error(e.getMessage()); 50 | } 51 | } 52 | 53 | @Override 54 | public void publish(String subject, GroupMessageDto message) { 55 | try { 56 | publish(subject, objectMapper.writeValueAsString(message)); 57 | } catch (Exception e) { 58 | logger.error(e.getMessage()); 59 | } 60 | } 61 | 62 | @Override 63 | public Subscription subscribe(String subject, MessageHandler handler) { 64 | return natsDispatcher.subscribe(subject, handler); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spring-chat 2 | 3 | [![build](https://github.com/joejoe2/spring-chat/actions/workflows/main.yml/badge.svg)](https://github.com/joejoe2/spring-chat/actions/workflows/main.yml) 4 | 5 | ## Description 6 | 7 | This is a horizontal scalable chat application using [Nats](https://github.com/nats-io/nats-server) as event bus. 8 | You can add as many application instances as you need to increase the capacity of 9 | online users in overall system. If you have even more users you can add Nats 10 | servers as cluster to increase the message delivery speed/throughput. 11 | 12 | ## Features 13 | 14 | - chat in public channels with any users (1 connection/channel) 15 | - chat in private channels with another user (1 connection/user) 16 | - chat in group channel with members (1 connection/user) 17 | - user blockage in private channels 18 | 19 | ## Supported Protocols 20 | 21 | You can use below protocols to receive/subscribe chat messages from the server: 22 | 23 | - Server Sent Event 24 | - Websocket 25 | 26 | ## Notice 27 | 28 | This project is using [our another project](https://github.com/joejoe2/spring-jwt-template) 29 | as authentication service for JWT authentication. 30 | 31 | ## Architecture 32 | 33 | ![image](architecture.png) 34 | 35 | You can see technical details at [here](./doc/chat-detail/README.md). 36 | 37 | ## Online demo 38 | 39 | [https://frontend.joejoe2.com](https://frontend.joejoe2.com) 40 | 41 | ## Example Frontend 42 | 43 | We provide an example frontend application written in Vue.js [at here](https://github.com/joejoe2/chat-frontend) to 44 | work with this project. 45 | 46 | - chat in public channel 47 | ![image](demo_public_chat.png) 48 | - chat in private channel 49 | ![image](demo_private_chat.png) 50 | - chat in group channel 51 | ![image](demo_group_chat.png) 52 | 53 | ## Testing 54 | 55 | run `mvn test` or `./mvnw test` 56 | 57 | ## Lint 58 | 59 | run 60 | ``` 61 | mvn spotless:apply 62 | ``` 63 | or 64 | ``` 65 | ./mvnw spotless:apply 66 | ``` 67 | 68 | ## Deploy 69 | 70 | 1. follow [this](https://github.com/joejoe2/spring-jwt-template#deploy) 71 | to setup 72 | 73 | 2. copy `./env/application-chat.env.example` to `./env/application-chat.env` 74 | and set `jwt.secret.publicKey` as in 1. 75 | 76 | 3. prepare 2 FQDNs (ex. `chat.test.com`, `auth.test.com`) 77 | 78 | 4. open `./nginx/user_conf.d/server.conf` to 79 | replace `chat.example.com` and `auth.example.com` with your 2 FQDNs 80 | 81 | 5. `docker-compose up` or `docker-compose up -d` 82 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/chat/TestContext.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat; 2 | 3 | import org.junit.jupiter.api.extension.BeforeAllCallback; 4 | import org.junit.jupiter.api.extension.ExtensionContext; 5 | import org.testcontainers.containers.GenericContainer; 6 | 7 | public class TestContext implements BeforeAllCallback, ExtensionContext.Store.CloseableResource { 8 | 9 | private static boolean started = false; 10 | 11 | static { 12 | // setup redis for all tests 13 | setUpRedis("6370"); 14 | // setup nats for all tests 15 | setUpNats("4221"); 16 | // setup db for all tests 17 | setupPostgres("5430"); 18 | } 19 | 20 | private static GenericContainer setUpRedis(String port) { 21 | GenericContainer redis = new GenericContainer("redis:6.2.7-alpine").withExposedPorts(6379); 22 | redis.getPortBindings().add(port + ":6379"); 23 | redis.start(); 24 | 25 | System.setProperty("spring.redis.host", redis.getContainerIpAddress()); 26 | System.setProperty("spring.redis.port", redis.getFirstMappedPort() + ""); 27 | return redis; 28 | } 29 | 30 | private static GenericContainer setUpNats(String port) { 31 | GenericContainer nats = new GenericContainer("nats:2.8").withExposedPorts(4222, 6222, 8222); 32 | nats.getPortBindings().add(port + ":4222"); 33 | nats.start(); 34 | 35 | System.setProperty( 36 | "nats.url", "nats://" + nats.getContainerIpAddress() + ":" + nats.getFirstMappedPort()); 37 | return nats; 38 | } 39 | 40 | private static GenericContainer setupPostgres(String port) { 41 | GenericContainer postgres = 42 | new GenericContainer("postgres:15.1") 43 | .withExposedPorts(5432) 44 | .withEnv("POSTGRES_PASSWORD", "pa55ward") 45 | .withEnv("POSTGRES_DB", "spring-chat"); 46 | postgres.getPortBindings().add(port + ":5432"); 47 | postgres.start(); 48 | 49 | System.setProperty( 50 | "spring.datasource.url", 51 | "jdbc:postgresql://" 52 | + postgres.getContainerIpAddress() 53 | + ":" 54 | + postgres.getFirstMappedPort() 55 | + "/spring-chat"); 56 | return postgres; 57 | } 58 | 59 | @Override 60 | public void beforeAll(ExtensionContext context) { 61 | if (!started) { 62 | started = true; 63 | // Your "before all tests" startup logic goes here 64 | } 65 | } 66 | 67 | @Override 68 | public void close() { 69 | // Your "after all tests" logic goes here 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/repository/channel/PrivateChannelRepository.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.repository.channel; 2 | 3 | import com.joejoe2.chat.models.PrivateChannel; 4 | import com.joejoe2.chat.models.User; 5 | import java.util.Arrays; 6 | import java.util.List; 7 | import java.util.Optional; 8 | import java.util.UUID; 9 | import org.springframework.cache.annotation.Cacheable; 10 | import org.springframework.data.domain.Pageable; 11 | import org.springframework.data.domain.Slice; 12 | import org.springframework.data.jpa.repository.JpaRepository; 13 | import org.springframework.data.jpa.repository.Query; 14 | import org.springframework.data.repository.query.Param; 15 | 16 | public interface PrivateChannelRepository extends JpaRepository { 17 | Optional findById(UUID id); 18 | 19 | Optional findByUniqueUserIds(String id); 20 | 21 | @Query( 22 | "SELECT DISTINCT ch from User u " 23 | + "join u.privateChannels ch where u = :user " 24 | + "ORDER BY ch.updateAt DESC") 25 | List findByMembersContainingUserByUpdateAtDesc(@Param("user") User user); 26 | 27 | default List findByIsUserInMembers(User user) { 28 | return findByMembersContainingUserByUpdateAtDesc(user); 29 | } 30 | 31 | @Query( 32 | "SELECT DISTINCT ch from User u " 33 | + "join u.privateChannels ch where u = :user " 34 | + "ORDER BY ch.updateAt DESC") 35 | Slice findByMembersContainingUserByUpdateAtDesc( 36 | @Param("user") User user, Pageable pageable); 37 | 38 | default Slice findByIsUserInMembers(User user, Pageable pageable) { 39 | return findByMembersContainingUserByUpdateAtDesc(user, pageable); 40 | } 41 | 42 | @Query( 43 | "SELECT DISTINCT ch from User u " 44 | + "join u.blockedPrivateChannels ch where u = :user " 45 | + "ORDER BY ch.createAt DESC") 46 | Slice findBlockedByUser(User user, Pageable pageable); 47 | 48 | default boolean isPrivateChannelExistBetween(User user1, User user2) { 49 | UUID[] ids = new UUID[] {user1.getId(), user2.getId()}; 50 | Arrays.sort(ids); 51 | return findByUniqueUserIds(ids[0].toString() + ids[1].toString()).isPresent(); 52 | } 53 | 54 | @Cacheable( 55 | value = "PrivateChannelMembers", 56 | key = "'PrivateChannelMembers:{'+ #id.toString() +'}'") 57 | @Query("SELECT u.id from PrivateChannel ch join ch.members u where ch.id = :id") 58 | List getMembersIdByChannel(@Param("id") UUID id); 59 | } 60 | -------------------------------------------------------------------------------- /src/main/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | jwt: 2 | secret: 3 | publicKey: | 4 | -----BEGIN PUBLIC KEY----- 5 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzFVaIiZtFKJgIrrXa9ZQ 6 | fHeGu3o/CFGAhybGXXcU6XWZpyIHNTUdx7ah1z+pMecXWqOIkmKVN92ktgV+TAEB 7 | mB91TMr23dMU95JC5wz7H1sxUmO+0HuA5XkGUTXf6GqpIAYLvKnNNhd8eCFm/YAE 8 | S9LMsRBVZqgAb7GDJDb+B4NTzUGtWn71/2rSnDsXg1+aV271MM7n20AcvRruXDWx 9 | bz5Wx5kKnTbwrOSvQ1chCo/gg+t+xCUdZ78SyT2bRuUIe+d0qHyqdY6i4lvbiXzC 10 | noZRygIMYfRyxh0y52Mw6NXLvowOZ2DDYtQMeJglyocOFeYqSgqiRsaELvoQ/5Y8 11 | 1wIDAQAB 12 | -----END PUBLIC KEY----- 13 | privateKey: | 14 | -----BEGIN PRIVATE KEY----- 15 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDMVVoiJm0UomAi 16 | utdr1lB8d4a7ej8IUYCHJsZddxTpdZmnIgc1NR3HtqHXP6kx5xdao4iSYpU33aS2 17 | BX5MAQGYH3VMyvbd0xT3kkLnDPsfWzFSY77Qe4DleQZRNd/oaqkgBgu8qc02F3x4 18 | IWb9gARL0syxEFVmqABvsYMkNv4Hg1PNQa1afvX/atKcOxeDX5pXbvUwzufbQBy9 19 | Gu5cNbFvPlbHmQqdNvCs5K9DVyEKj+CD637EJR1nvxLJPZtG5Qh753SofKp1jqLi 20 | W9uJfMKehlHKAgxh9HLGHTLnYzDo1cu+jA5nYMNi1Ax4mCXKhw4V5ipKCqJGxoQu 21 | +hD/ljzXAgMBAAECggEAR8T26qXKjIPX9nrf7V2SWZV1+mWevCI8Xbwt0mhgLPwE 22 | YyLdmz+z3RD12W/f0spTdp+X+aqstLmh/9kAGlwEHlV2Uum7OgDJDYgO/a6eic3z 23 | DfhA7mNiy7btlBqzMaQ9ESVue+68SHKJYnyA+ys61xMMmGifRnZd6N1VraOvKB3E 24 | 5s3CSLpbAaAJbXew/UxiShzxC1mswYdQqT+uwv4BGWSKrtNKWUP6Vd7PNiOSVDCq 25 | mPMBxj3Y2JYZr9TBEaGD3XAN+mRC2EuyZjFvpR7jPgPdLFLE63IAcq7rUtJEYucC 26 | 9NoDpH6UPlXJLNJt2hyiAx0YLcN0iNwyyUlMpdC1IQKBgQDmUcTW3qZnycrmEeK5 27 | 9Zh39+VKfClAzZNbM3BRuJ0RxareWYrvwEbOnChlJWIgvjKokFKhkU0YJ50mD+Cv 28 | BqmZCbG7B74r4GB19V/PMwRXvoBcso0rMZ1mP0AoYKXPrjrvNsqBm/8UZZLL3vWr 29 | ZFI1XVEdLZ0bM0ys9rJKH94T5wKBgQDjHdbitjP/UurnIT+BHqObQXDrmEqAansY 30 | 93wlrb3KNTfrYts2ULIJVVocOF9WW274bmb/3FusQOOtERrO4vOnPfDebFO3e+VP 31 | /gNVnAch5gcOaaHRYhYUbSh80oCT34orhqqfo6pGXI1zD8TbhrVcwVsP0KmwteTD 32 | qc4cc1xxkQKBgQCJVHY+/HFSb2MY/c8nvIYV+mzwlcnvRuS3O5ucTqzxHOC+Rbvv 33 | KsHNjhUUAk9ZYK9KDQwIJGBIp84vFMaO9jUH+FzOPVaqSNabXxyqqivLud5F53z/ 34 | JU1J2ysBKGeVxriDTDNBRue4nLwD7cSkVmQiR6sG79y+jD8K3un+ArRjPwKBgHvj 35 | GgV/CCwdad98JmzjbrFQ6CzLXNBhxRYgYcsX0/BKSV+QBC3DpOosccP1CCROKeFA 36 | L9Ufua3jk44jR3FVIT24LvzVMHFlFvgkgmMfglB+bpjxDADwNUUdKjm0hcij5nXJ 37 | tqbwGwDYmZwLHQH2oFWhb2/YDchD4C7PIIwqbWHRAoGAK70oggnIysqOy0SalAAb 38 | 1XJQy3RopmVwhoh2VDodfe5DWvJKJVgSHv7gN8/uUFWGW2v7aiylsqH8pCi6Mywz 39 | mocETa/9vy9WAXhrkz19Ui07j6wObApnhthvCAoVerXetmv/hhXlyACm2tWeRYhE 40 | FOgtIo7gTMxb2dBgGpOxq+o= 41 | -----END PRIVATE KEY----- 42 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/models/PrivateMessage.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.models; 2 | 3 | import jakarta.persistence.*; 4 | import java.time.Instant; 5 | import java.util.Objects; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | import org.hibernate.annotations.OnDelete; 11 | import org.hibernate.annotations.OnDeleteAction; 12 | 13 | @Builder 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | @Data 17 | @Entity 18 | @Table( 19 | name = "private_message", 20 | indexes = { 21 | @Index(columnList = "to_id"), 22 | @Index(columnList = "from_id"), 23 | @Index(columnList = "channel_id"), 24 | @Index(columnList = "updateAt DESC") 25 | }) 26 | public class PrivateMessage extends TimeStampBase { 27 | @Version 28 | @Column(nullable = false, columnDefinition = "TIMESTAMP DEFAULT now()") 29 | private Instant version; 30 | 31 | @ManyToOne(optional = false, fetch = FetchType.LAZY) 32 | @OnDelete(action = OnDeleteAction.CASCADE) 33 | private PrivateChannel channel; 34 | 35 | @Column(length = 32, nullable = false) 36 | @Enumerated(EnumType.STRING) 37 | private MessageType messageType = MessageType.MESSAGE; // code level default 38 | 39 | @ManyToOne(optional = false, fetch = FetchType.LAZY) 40 | User from; 41 | 42 | @ManyToOne(optional = false, fetch = FetchType.LAZY) 43 | User to; 44 | 45 | @Column(columnDefinition = "TEXT", nullable = false) 46 | private String content; 47 | 48 | public PrivateMessage(PrivateChannel channel, User from, User to, String content) { 49 | if (Objects.equals(from, to)) 50 | throw new IllegalArgumentException("from cannot be same with to !"); 51 | this.channel = channel; 52 | this.from = from; 53 | this.to = to; 54 | this.content = content; 55 | } 56 | 57 | @Override 58 | public boolean equals(Object o) { 59 | if (this == o) return true; 60 | if (!(o instanceof PrivateMessage)) return false; 61 | PrivateMessage that = (PrivateMessage) o; 62 | return Objects.equals(id, that.id); 63 | } 64 | 65 | @Override 66 | public int hashCode() { 67 | return Objects.hash(id); 68 | } 69 | 70 | @Override 71 | public String toString() { 72 | return "PrivateMessage{" 73 | + "id=" 74 | + id 75 | + ", channel=" 76 | + channel 77 | + ", messageType=" 78 | + messageType 79 | + ", from=" 80 | + from 81 | + ", to=" 82 | + to 83 | + ", content='" 84 | + content 85 | + '\'' 86 | + ", createAt=" 87 | + createAt 88 | + ", updateAt=" 89 | + updateAt 90 | + '}'; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/SpringDocConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import com.joejoe2.chat.data.UserPublicProfile; 4 | import io.swagger.v3.core.converter.AnnotatedType; 5 | import io.swagger.v3.core.converter.ModelConverters; 6 | import io.swagger.v3.core.converter.ResolvedSchema; 7 | import io.swagger.v3.oas.annotations.OpenAPIDefinition; 8 | import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; 9 | import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; 10 | import io.swagger.v3.oas.annotations.info.Info; 11 | import io.swagger.v3.oas.annotations.security.SecurityScheme; 12 | import io.swagger.v3.oas.annotations.security.SecuritySchemes; 13 | import io.swagger.v3.oas.models.Components; 14 | import io.swagger.v3.oas.models.OpenAPI; 15 | import io.swagger.v3.oas.models.media.Schema; 16 | import org.springframework.context.annotation.Bean; 17 | import org.springframework.context.annotation.Configuration; 18 | 19 | @OpenAPIDefinition(info = @Info(title = "Spring Chat API", version = "v0.0.1")) 20 | @SecuritySchemes({ 21 | @SecurityScheme( 22 | name = "jwt", 23 | scheme = "bearer", 24 | bearerFormat = "jwt", 25 | type = SecuritySchemeType.HTTP, 26 | in = SecuritySchemeIn.HEADER), 27 | @SecurityScheme( 28 | name = "jwt-in-query", 29 | paramName = "access_token", 30 | type = SecuritySchemeType.APIKEY, 31 | in = SecuritySchemeIn.QUERY) 32 | }) 33 | @Configuration 34 | public class SpringDocConfig { 35 | @Bean 36 | public OpenAPI customOpenAPI() { 37 | return new OpenAPI() 38 | .components( 39 | new Components() 40 | .addSchemas( 41 | "Sender", 42 | getSchemaWithDifferentDescription( 43 | UserPublicProfile.class, "profile of the sender")) 44 | .addSchemas( 45 | "Receiver", 46 | getSchemaWithDifferentDescription( 47 | UserPublicProfile.class, 48 | "profile of the receiver, null if the message is in public channel", 49 | true))); 50 | } 51 | 52 | private Schema getSchemaWithDifferentDescription(Class className, String description) { 53 | ResolvedSchema resolvedSchema = 54 | ModelConverters.getInstance() 55 | .resolveAsResolvedSchema(new AnnotatedType(className).resolveAsRef(false)); 56 | return resolvedSchema.schema.description(description); 57 | } 58 | 59 | private Schema getSchemaWithDifferentDescription( 60 | Class className, String description, Boolean nullable) { 61 | ResolvedSchema resolvedSchema = 62 | ModelConverters.getInstance() 63 | .resolveAsResolvedSchema(new AnnotatedType(className).resolveAsRef(false)); 64 | return resolvedSchema.schema.description(description).nullable(nullable); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/utils/JwtUtil.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.utils; 2 | 3 | import com.joejoe2.chat.data.UserDetail; 4 | import com.joejoe2.chat.exception.InvalidTokenException; 5 | import com.joejoe2.chat.models.User; 6 | import io.jsonwebtoken.Claims; 7 | import io.jsonwebtoken.JwtException; 8 | import io.jsonwebtoken.JwtParser; 9 | import io.jsonwebtoken.Jwts; 10 | import java.security.interfaces.RSAPrivateKey; 11 | import java.security.interfaces.RSAPublicKey; 12 | import java.util.Arrays; 13 | import java.util.Calendar; 14 | import java.util.Map; 15 | import java.util.stream.Collectors; 16 | 17 | public class JwtUtil { 18 | public static String generateAccessToken( 19 | RSAPrivateKey key, String jti, String issuer, User user, Calendar exp) { 20 | Claims claims = Jwts.claims(); 21 | claims.put("type", "access_token"); 22 | claims.put("id", user.getId().toString()); 23 | claims.put("username", user.getUserName()); 24 | claims.setExpiration(exp.getTime()); 25 | claims.setIssuer(issuer); 26 | claims.setId(jti); 27 | 28 | return Jwts.builder().setClaims(claims).signWith(key).compact(); 29 | } 30 | 31 | public static Map parseToken(RSAPublicKey key, String token) throws JwtException { 32 | try { 33 | JwtParser parser = Jwts.parserBuilder().setSigningKey(key).build(); 34 | 35 | Claims claims = parser.parseClaimsJws(token).getBody(); 36 | 37 | return claims.entrySet().stream() 38 | .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); 39 | } catch (Exception e) { 40 | throw new JwtException(e.getMessage()); 41 | } 42 | } 43 | 44 | public static Map parseToken(JwtParser parser, String token) throws JwtException { 45 | try { 46 | Claims claims = parser.parseClaimsJws(token).getBody(); 47 | return claims.entrySet().stream() 48 | .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); 49 | } catch (Exception e) { 50 | throw new JwtException(e.getMessage()); 51 | } 52 | } 53 | 54 | private static final String[] REQUIRED_FIELDS = new String[] {"type", "id", "username"}; 55 | 56 | public static UserDetail extractUserDetailFromAccessToken(JwtParser parser, String token) 57 | throws InvalidTokenException { 58 | try { 59 | Map data = JwtUtil.parseToken(parser, token); 60 | if (Arrays.stream(REQUIRED_FIELDS).anyMatch((f) -> data.get(f) == null)) { 61 | throw new InvalidTokenException("invalid token !"); 62 | } 63 | if (!data.get("type").equals("access_token")) { 64 | throw new InvalidTokenException("invalid token !"); 65 | } 66 | return new UserDetail((String) data.get("id"), (String) data.get("username")); 67 | } catch (Exception ex) { 68 | throw new InvalidTokenException("invalid token !"); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /nginx/user_conf.d/server.conf: -------------------------------------------------------------------------------- 1 | server { 2 | # Listen to port 443 on both IPv4 and IPv6. 3 | listen 443 ssl; 4 | listen [::]:443 ssl; 5 | 6 | # Domain names this server should respond to. 7 | server_name auth.example.com; 8 | 9 | # Load the certificate files. 10 | ssl_certificate /etc/letsencrypt/live/test-name/fullchain.pem; 11 | ssl_certificate_key /etc/letsencrypt/live/test-name/privkey.pem; 12 | ssl_trusted_certificate /etc/letsencrypt/live/test-name/chain.pem; 13 | 14 | # Load the Diffie-Hellman parameter. 15 | ssl_dhparam /etc/letsencrypt/dhparams/dhparam.pem; 16 | 17 | # proxy 18 | location / { 19 | proxy_pass http://web:8080; 20 | proxy_set_header Host $host; 21 | proxy_set_header X-Real-IP $remote_addr; 22 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 23 | proxy_set_header REMOTE-HOST $remote_addr; 24 | proxy_set_header X-Forwarded-Proto $scheme; 25 | proxy_redirect off; 26 | } 27 | } 28 | 29 | server { 30 | # Listen to port 443 on both IPv4 and IPv6. 31 | listen 443 ssl http2; 32 | listen [::]:443 ssl http2; 33 | 34 | # Domain names this server should respond to. 35 | server_name chat.example.com; 36 | 37 | # Load the certificate files. 38 | ssl_certificate /etc/letsencrypt/live/test-name/fullchain.pem; 39 | ssl_certificate_key /etc/letsencrypt/live/test-name/privkey.pem; 40 | ssl_trusted_certificate /etc/letsencrypt/live/test-name/chain.pem; 41 | 42 | # Load the Diffie-Hellman parameter. 43 | ssl_dhparam /etc/letsencrypt/dhparams/dhparam.pem; 44 | 45 | # proxy 46 | location /api/ { 47 | proxy_pass http://chat:8080; 48 | proxy_set_header Host $host; 49 | proxy_set_header X-Real-IP $remote_addr; 50 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 51 | proxy_set_header REMOTE-HOST $remote_addr; 52 | proxy_set_header X-Forwarded-Proto $scheme; 53 | proxy_redirect off; 54 | # Server Sent Event 55 | proxy_read_timeout 2m; 56 | proxy_set_header Connection ''; 57 | proxy_http_version 1.1; 58 | chunked_transfer_encoding off; 59 | proxy_buffering off; 60 | proxy_cache off; 61 | } 62 | 63 | # proxy 64 | location /ws/ { 65 | proxy_pass http://chat:8080; 66 | proxy_http_version 1.1; 67 | proxy_set_header Host $host; 68 | proxy_set_header X-Real-IP $remote_addr; 69 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 70 | proxy_set_header REMOTE-HOST $remote_addr; 71 | proxy_set_header X-Forwarded-Proto $scheme; 72 | proxy_redirect off; 73 | # WebSocket 74 | proxy_connect_timeout 4s; 75 | proxy_read_timeout 15m; 76 | proxy_send_timeout 12s; 77 | proxy_set_header Upgrade $http_upgrade; 78 | proxy_set_header Connection "upgrade"; 79 | } 80 | } -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/controller/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.controller; 2 | 3 | import com.joejoe2.chat.data.ErrorMessageResponse; 4 | import com.joejoe2.chat.data.InvalidRequestResponse; 5 | import io.swagger.v3.oas.annotations.media.Content; 6 | import io.swagger.v3.oas.annotations.media.ExampleObject; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 9 | import java.util.HashMap; 10 | import java.util.Map; 11 | import java.util.TreeSet; 12 | import org.springframework.http.HttpHeaders; 13 | import org.springframework.http.HttpStatus; 14 | import org.springframework.http.HttpStatusCode; 15 | import org.springframework.http.ResponseEntity; 16 | import org.springframework.http.converter.HttpMessageNotReadableException; 17 | import org.springframework.validation.FieldError; 18 | import org.springframework.web.bind.MethodArgumentNotValidException; 19 | import org.springframework.web.bind.annotation.ControllerAdvice; 20 | import org.springframework.web.bind.annotation.ExceptionHandler; 21 | import org.springframework.web.context.request.WebRequest; 22 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; 23 | 24 | @ControllerAdvice 25 | public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { 26 | 27 | @Override 28 | protected ResponseEntity handleHttpMessageNotReadable( 29 | HttpMessageNotReadableException ex, 30 | HttpHeaders headers, 31 | HttpStatusCode status, 32 | WebRequest request) { 33 | return super.handleHttpMessageNotReadable(ex, headers, status, request); 34 | } 35 | 36 | @Override 37 | @ApiResponse( 38 | responseCode = "400", 39 | description = "field errors in request body/param", 40 | content = 41 | @Content( 42 | mediaType = "application/json", 43 | schema = @Schema(implementation = InvalidRequestResponse.class), 44 | examples = 45 | @ExampleObject( 46 | value = 47 | "{\"errors\":{\"field1\":[\"msg1\",\"msg2\"], " 48 | + "\"field2\":[...], ...}}"))) 49 | protected ResponseEntity handleMethodArgumentNotValid( 50 | MethodArgumentNotValidException ex, 51 | HttpHeaders headers, 52 | HttpStatusCode status, 53 | WebRequest request) { 54 | Map> errors = new HashMap<>(); 55 | for (FieldError error : ex.getFieldErrors()) { 56 | TreeSet messages = errors.getOrDefault(error.getField(), new TreeSet<>()); 57 | messages.add(error.getDefaultMessage()); 58 | errors.put(error.getField(), messages); 59 | } 60 | return ResponseEntity.badRequest().body(new InvalidRequestResponse(errors)); 61 | } 62 | 63 | @ExceptionHandler(RuntimeException.class) 64 | @ApiResponse( 65 | responseCode = "500", 66 | description = "internal server error", 67 | content = 68 | @Content( 69 | mediaType = "application/json", 70 | schema = @Schema(implementation = ErrorMessageResponse.class))) 71 | public ResponseEntity handleRuntimeException(Exception ex, WebRequest request) { 72 | ex.printStackTrace(); 73 | return new ResponseEntity<>( 74 | new ErrorMessageResponse("unknown error, please try again later !"), 75 | HttpStatus.INTERNAL_SERVER_ERROR); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/channel/PrivateChannelService.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.channel; 2 | 3 | import com.joejoe2.chat.data.PageRequest; 4 | import com.joejoe2.chat.data.SliceList; 5 | import com.joejoe2.chat.data.channel.profile.PrivateChannelProfile; 6 | import com.joejoe2.chat.exception.AlreadyExist; 7 | import com.joejoe2.chat.exception.ChannelDoesNotExist; 8 | import com.joejoe2.chat.exception.InvalidOperation; 9 | import com.joejoe2.chat.exception.UserDoesNotExist; 10 | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 11 | import org.springframework.web.socket.WebSocketSession; 12 | 13 | public interface PrivateChannelService { 14 | /** 15 | * subscribe to all private channels of target user 16 | * 17 | * @param fromUserId id of target user 18 | * @return SseEmitter 19 | * @throws UserDoesNotExist 20 | */ 21 | SseEmitter subscribe(String fromUserId) throws UserDoesNotExist; 22 | 23 | /** 24 | * subscribe to all private channels of target user 25 | * 26 | * @param fromUserId id of target user 27 | * @param session WebSocketSession 28 | * @throws UserDoesNotExist 29 | */ 30 | void subscribe(WebSocketSession session, String fromUserId) throws UserDoesNotExist; 31 | 32 | /** 33 | * create a private channel between two users(from and to) 34 | * 35 | * @param fromUserId id of user1 36 | * @param toUserId id of user2 37 | * @return 38 | * @throws AlreadyExist 39 | * @throws UserDoesNotExist 40 | * @throws InvalidOperation if fromUserId==toUserId 41 | */ 42 | PrivateChannelProfile createChannelBetween(String fromUserId, String toUserId) 43 | throws AlreadyExist, UserDoesNotExist, InvalidOperation; 44 | 45 | /** 46 | * get all private channels of target user with page 47 | * 48 | * @param ofUserId id of target user 49 | * @param pageRequest 50 | * @return 51 | * @throws UserDoesNotExist 52 | */ 53 | SliceList getAllChannels(String ofUserId, PageRequest pageRequest) 54 | throws UserDoesNotExist; 55 | 56 | /** 57 | * get profile of target channel of target user 58 | * 59 | * @param ofUserId id of target user 60 | * @param channelId id of target channel 61 | * @return profile of target channel 62 | * @throws UserDoesNotExist 63 | * @throws ChannelDoesNotExist 64 | * @throws InvalidOperation user is not in members of target channel 65 | */ 66 | PrivateChannelProfile getChannelProfile(String ofUserId, String channelId) 67 | throws UserDoesNotExist, ChannelDoesNotExist, InvalidOperation; 68 | 69 | /** 70 | * get all private channels blocked by the user with page 71 | * 72 | * @param userId id of the user 73 | * @param pageRequest 74 | * @return 75 | * @throws UserDoesNotExist 76 | */ 77 | SliceList getChannelsBlockedByUser(String userId, PageRequest pageRequest) 78 | throws UserDoesNotExist; 79 | 80 | /** 81 | * let user block or unblock the channel(another user) 82 | * 83 | * @param userId user id of the initiator 84 | * @param channelId id of target channel 85 | * @param isBlock block or unblock 86 | * @throws UserDoesNotExist 87 | * @throws ChannelDoesNotExist 88 | * @throws InvalidOperation user is not in members of target channel 89 | */ 90 | void block(String userId, String channelId, boolean isBlock) 91 | throws UserDoesNotExist, ChannelDoesNotExist, InvalidOperation; 92 | } 93 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/config/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.config; 2 | 3 | import com.joejoe2.chat.filter.JwtAuthenticationFilter; 4 | import com.joejoe2.chat.service.user.UserService; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.security.authentication.AuthenticationManager; 8 | import org.springframework.security.config.Customizer; 9 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 10 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 11 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 12 | import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 13 | import org.springframework.security.config.http.SessionCreationPolicy; 14 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 15 | import org.springframework.security.crypto.password.PasswordEncoder; 16 | import org.springframework.security.web.SecurityFilterChain; 17 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 18 | import org.springframework.web.cors.CorsConfiguration; 19 | import org.springframework.web.cors.CorsConfigurationSource; 20 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 21 | 22 | @EnableWebSecurity 23 | @Configuration 24 | public class SecurityConfig { 25 | private final UserService userService; 26 | private final JwtAuthenticationFilter jwtAuthenticationFilter; 27 | 28 | public SecurityConfig(UserService userService, JwtAuthenticationFilter jwtAuthenticationFilter) { 29 | this.userService = userService; 30 | this.jwtAuthenticationFilter = jwtAuthenticationFilter; 31 | } 32 | 33 | @Bean 34 | PasswordEncoder passwordEncoder() { 35 | return new BCryptPasswordEncoder(); 36 | } 37 | 38 | @Bean 39 | public SecurityFilterChain configure(HttpSecurity http) throws Exception { 40 | // blank will allow any request 41 | return http.cors(Customizer.withDefaults()) 42 | .csrf(AbstractHttpConfigurer::disable) 43 | .sessionManagement( 44 | session -> 45 | session.sessionCreationPolicy( 46 | SessionCreationPolicy.NEVER)) // use jwt instead of session 47 | .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) 48 | .formLogin(AbstractHttpConfigurer::disable) 49 | .build(); 50 | } 51 | 52 | @Bean 53 | public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception { 54 | // retrieve builder from httpSecurity 55 | AuthenticationManagerBuilder authenticationManagerBuilder = 56 | http.getSharedObject(AuthenticationManagerBuilder.class); 57 | authenticationManagerBuilder.userDetailsService(userService).passwordEncoder(passwordEncoder()); 58 | return authenticationManagerBuilder.build(); 59 | } 60 | 61 | @Bean 62 | CorsConfigurationSource corsConfigurationSource() { 63 | CorsConfiguration apiConfiguration = new CorsConfiguration(); 64 | apiConfiguration.addAllowedOrigin("*"); 65 | apiConfiguration.addAllowedHeader("*"); 66 | apiConfiguration.addAllowedMethod("*"); 67 | UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 68 | source.registerCorsConfiguration("/api/**", apiConfiguration); 69 | return source; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/data/message/MessageDto.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.data.message; 2 | 3 | import com.fasterxml.jackson.annotation.JsonTypeInfo; 4 | import com.joejoe2.chat.data.UserPublicProfile; 5 | import com.joejoe2.chat.models.MessageType; 6 | import com.joejoe2.chat.utils.TimeUtil; 7 | import io.swagger.v3.oas.annotations.media.Schema; 8 | import java.time.Instant; 9 | import java.time.temporal.ChronoUnit; 10 | import java.util.Objects; 11 | import java.util.UUID; 12 | import lombok.Data; 13 | import lombok.NoArgsConstructor; 14 | import lombok.extern.jackson.Jacksonized; 15 | 16 | @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) 17 | @Jacksonized 18 | @NoArgsConstructor 19 | @Data 20 | public class MessageDto { 21 | @Schema(description = "version of the message") 22 | protected Double version; 23 | 24 | @Schema(description = "id of the message") 25 | protected UUID id; 26 | 27 | @Schema(description = "channel id of the message") 28 | protected UUID channel; 29 | 30 | @Schema(description = "type of the message") 31 | protected MessageType messageType; 32 | 33 | @Schema(ref = "Sender") 34 | protected UserPublicProfile from; 35 | 36 | @Schema(ref = "Receiver") 37 | protected UserPublicProfile to; 38 | 39 | @Schema(description = "content of the message") 40 | protected String content; 41 | 42 | @Schema(description = "when is the message created") 43 | protected String createAt; 44 | 45 | @Schema(description = "when is the message updated") 46 | protected String updateAt; 47 | 48 | public MessageDto( 49 | Instant version, 50 | UUID id, 51 | UUID channel, 52 | MessageType messageType, 53 | UserPublicProfile from, 54 | String content, 55 | String createAt, 56 | String updateAt) { 57 | this.version = 58 | ChronoUnit.MICROS.between(Instant.EPOCH, TimeUtil.roundToMicro(version)) / 1000.0; 59 | this.id = id; 60 | this.channel = channel; 61 | this.messageType = messageType; 62 | this.from = from; 63 | this.content = content; 64 | this.createAt = createAt; 65 | this.updateAt = updateAt; 66 | } 67 | 68 | public MessageDto( 69 | Instant version, 70 | UUID id, 71 | UUID channel, 72 | MessageType messageType, 73 | UserPublicProfile from, 74 | UserPublicProfile to, 75 | String content, 76 | String createAt, 77 | String updateAt) { 78 | this.version = 79 | ChronoUnit.MICROS.between(Instant.EPOCH, TimeUtil.roundToMicro(version)) / 1000.0; 80 | this.id = id; 81 | this.channel = channel; 82 | this.messageType = messageType; 83 | this.from = from; 84 | this.to = to; 85 | this.content = content; 86 | this.createAt = createAt; 87 | this.updateAt = updateAt; 88 | } 89 | 90 | @Override 91 | public boolean equals(Object o) { 92 | if (this == o) return true; 93 | if (!(o instanceof MessageDto that)) return false; 94 | return version.equals(that.version) 95 | && id.equals(that.id) 96 | && channel.equals(that.channel) 97 | && messageType == that.messageType 98 | && from.equals(that.from) 99 | && Objects.equals(to, that.to) 100 | && content.equals(that.content) 101 | && createAt.equals(that.createAt) 102 | && updateAt.equals(that.updateAt); 103 | } 104 | 105 | @Override 106 | public int hashCode() { 107 | return Objects.hash(version, id, channel, messageType, from, to, content, createAt, updateAt); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/main/resources/db/changelog/2023/10/10-04-changelog.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: 1697336677537-3 4 | author: joejoe2 (generated) 5 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 6 | changes: 7 | - createTable: 8 | columns: 9 | - column: 10 | constraints: 11 | nullable: false 12 | primaryKey: true 13 | primaryKeyName: pk_group_channels_administrators 14 | name: group_channel_id 15 | type: UUID 16 | - column: 17 | constraints: 18 | nullable: false 19 | primaryKey: true 20 | primaryKeyName: pk_group_channels_administrators 21 | name: user_id 22 | type: UUID 23 | tableName: group_channels_administrators 24 | - changeSet: 25 | id: 1697336677537-4 26 | author: joejoe2 (generated) 27 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 28 | changes: 29 | - createTable: 30 | columns: 31 | - column: 32 | constraints: 33 | nullable: false 34 | primaryKey: true 35 | primaryKeyName: pk_group_channels_banned_users 36 | name: group_channel_id 37 | type: UUID 38 | - column: 39 | constraints: 40 | nullable: false 41 | primaryKey: true 42 | primaryKeyName: pk_group_channels_banned_users 43 | name: user_id 44 | type: UUID 45 | tableName: group_channels_banned_users 46 | - changeSet: 47 | id: 1697336677537-5 48 | author: joejoe2 (generated) 49 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 50 | changes: 51 | - addForeignKeyConstraint: 52 | baseColumnNames: group_channel_id 53 | baseTableName: group_channels_administrators 54 | constraintName: fk_grochaadm_on_group_channel 55 | referencedColumnNames: id 56 | referencedTableName: group_channel 57 | - changeSet: 58 | id: 1697336677537-6 59 | author: joejoe2 (generated) 60 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 61 | changes: 62 | - addForeignKeyConstraint: 63 | baseColumnNames: user_id 64 | baseTableName: group_channels_administrators 65 | constraintName: fk_grochaadm_on_user 66 | referencedColumnNames: id 67 | referencedTableName: account_user 68 | - changeSet: 69 | id: 1697336677537-7 70 | author: joejoe2 (generated) 71 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 72 | changes: 73 | - addForeignKeyConstraint: 74 | baseColumnNames: group_channel_id 75 | baseTableName: group_channels_banned_users 76 | constraintName: fk_grochabanuse_on_group_channel 77 | referencedColumnNames: id 78 | referencedTableName: group_channel 79 | - changeSet: 80 | id: 1697336677537-8 81 | author: joejoe2 (generated) 82 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 83 | changes: 84 | - addForeignKeyConstraint: 85 | baseColumnNames: user_id 86 | baseTableName: group_channels_banned_users 87 | constraintName: fk_grochabanuse_on_user 88 | referencedColumnNames: id 89 | referencedTableName: account_user 90 | 91 | -------------------------------------------------------------------------------- /src/main/resources/db/test/changelog/2023/10/10-04-changelog.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - changeSet: 3 | id: 1697336677537-3 4 | author: joejoe2 (generated) 5 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 6 | changes: 7 | - createTable: 8 | columns: 9 | - column: 10 | constraints: 11 | nullable: false 12 | primaryKey: true 13 | primaryKeyName: pk_group_channels_administrators 14 | name: group_channel_id 15 | type: UUID 16 | - column: 17 | constraints: 18 | nullable: false 19 | primaryKey: true 20 | primaryKeyName: pk_group_channels_administrators 21 | name: user_id 22 | type: UUID 23 | tableName: group_channels_administrators 24 | - changeSet: 25 | id: 1697336677537-4 26 | author: joejoe2 (generated) 27 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 28 | changes: 29 | - createTable: 30 | columns: 31 | - column: 32 | constraints: 33 | nullable: false 34 | primaryKey: true 35 | primaryKeyName: pk_group_channels_banned_users 36 | name: group_channel_id 37 | type: UUID 38 | - column: 39 | constraints: 40 | nullable: false 41 | primaryKey: true 42 | primaryKeyName: pk_group_channels_banned_users 43 | name: user_id 44 | type: UUID 45 | tableName: group_channels_banned_users 46 | - changeSet: 47 | id: 1697336677537-5 48 | author: joejoe2 (generated) 49 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 50 | changes: 51 | - addForeignKeyConstraint: 52 | baseColumnNames: group_channel_id 53 | baseTableName: group_channels_administrators 54 | constraintName: fk_grochaadm_on_group_channel 55 | referencedColumnNames: id 56 | referencedTableName: group_channel 57 | - changeSet: 58 | id: 1697336677537-6 59 | author: joejoe2 (generated) 60 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 61 | changes: 62 | - addForeignKeyConstraint: 63 | baseColumnNames: user_id 64 | baseTableName: group_channels_administrators 65 | constraintName: fk_grochaadm_on_user 66 | referencedColumnNames: id 67 | referencedTableName: account_user 68 | - changeSet: 69 | id: 1697336677537-7 70 | author: joejoe2 (generated) 71 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 72 | changes: 73 | - addForeignKeyConstraint: 74 | baseColumnNames: group_channel_id 75 | baseTableName: group_channels_banned_users 76 | constraintName: fk_grochabanuse_on_group_channel 77 | referencedColumnNames: id 78 | referencedTableName: group_channel 79 | - changeSet: 80 | id: 1697336677537-8 81 | author: joejoe2 (generated) 82 | objectQuotingStrategy: QUOTE_ONLY_RESERVED_WORDS 83 | changes: 84 | - addForeignKeyConstraint: 85 | baseColumnNames: user_id 86 | baseTableName: group_channels_banned_users 87 | constraintName: fk_grochabanuse_on_user 88 | referencedColumnNames: id 89 | referencedTableName: account_user 90 | 91 | -------------------------------------------------------------------------------- /src/test/java/com/joejoe2/chat/service/channel/PublicChannelServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.channel; 2 | 3 | import static org.junit.jupiter.api.Assertions.*; 4 | 5 | import com.joejoe2.chat.TestContext; 6 | import com.joejoe2.chat.data.PageRequest; 7 | import com.joejoe2.chat.data.channel.profile.PublicChannelProfile; 8 | import com.joejoe2.chat.exception.AlreadyExist; 9 | import com.joejoe2.chat.exception.ChannelDoesNotExist; 10 | import com.joejoe2.chat.models.User; 11 | import com.joejoe2.chat.repository.channel.PublicChannelRepository; 12 | import com.joejoe2.chat.repository.message.PublicMessageRepository; 13 | import com.joejoe2.chat.repository.user.UserRepository; 14 | import java.util.*; 15 | import org.junit.jupiter.api.AfterEach; 16 | import org.junit.jupiter.api.BeforeEach; 17 | import org.junit.jupiter.api.Test; 18 | import org.junit.jupiter.api.extension.ExtendWith; 19 | import org.springframework.beans.factory.annotation.Autowired; 20 | import org.springframework.boot.test.context.SpringBootTest; 21 | import org.springframework.test.context.ActiveProfiles; 22 | 23 | @SpringBootTest 24 | @ActiveProfiles("test") 25 | @ExtendWith(TestContext.class) 26 | class PublicChannelServiceTest { 27 | @Autowired PublicChannelService channelService; 28 | @Autowired PublicChannelRepository channelRepository; 29 | @Autowired PublicMessageRepository messageRepository; 30 | @Autowired UserRepository userRepository; 31 | 32 | User userA, userB, userC, userD; 33 | 34 | @BeforeEach 35 | void setUp() { 36 | userA = 37 | User.builder() 38 | .id(UUID.fromString("2354705e-cabf-40dd-b9c5-47a6f1bd5a2d")) 39 | .userName("A") 40 | .build(); 41 | userB = 42 | User.builder() 43 | .id(UUID.fromString("2354705e-cabf-40dd-b9c5-47a6f1bd5a3d")) 44 | .userName("B") 45 | .build(); 46 | userC = 47 | User.builder() 48 | .id(UUID.fromString("2354705e-cabf-40dd-b9c5-47a6f1bd5a4d")) 49 | .userName("C") 50 | .build(); 51 | userD = 52 | User.builder() 53 | .id(UUID.fromString("2354705e-cabf-40dd-b9c5-47a6f1bd5a5d")) 54 | .userName("D") 55 | .build(); 56 | userRepository.saveAll(Arrays.asList(userA, userB, userC, userD)); 57 | } 58 | 59 | @AfterEach 60 | void tearDown() { 61 | messageRepository.deleteAll(); 62 | channelRepository.deleteAll(); 63 | userRepository.deleteAll(); 64 | } 65 | 66 | @Test 67 | void createChannel() { 68 | // test IllegalArgument 69 | assertThrows( 70 | IllegalArgumentException.class, () -> channelService.createChannel("invalid_name")); 71 | // test success 72 | assertDoesNotThrow( 73 | () -> { 74 | PublicChannelProfile channel = channelService.createChannel("test"); 75 | assertEquals("test", channel.getName()); 76 | }); 77 | // test AlreadyExist 78 | assertThrows(AlreadyExist.class, () -> channelService.createChannel("test")); 79 | } 80 | 81 | @Test 82 | void getAllChannelsWithPage() throws Exception { 83 | // prepare channels 84 | List channels = new LinkedList<>(); 85 | for (int i = 0; i < 50; i++) { 86 | channels.add(channelService.createChannel("test" + i)); 87 | } 88 | channels.sort(Comparator.comparing(PublicChannelProfile::getName)); 89 | // test IllegalArgument 90 | assertThrows( 91 | IllegalArgumentException.class, 92 | () -> channelService.getAllChannels(PageRequest.builder().page(-1).size(0).build())); 93 | // test success 94 | assertEquals( 95 | channels.subList(10, 15), 96 | channelService.getAllChannels(PageRequest.builder().page(2).size(5).build()).getList()); 97 | } 98 | 99 | @Test 100 | void getChannelProfile() throws Exception { 101 | // prepare channels 102 | PublicChannelProfile channel = channelService.createChannel("test"); 103 | // test IllegalArgument 104 | assertThrows( 105 | IllegalArgumentException.class, () -> channelService.getChannelProfile("invalid id")); 106 | // test ChannelDoesNotExist 107 | String id = UUID.randomUUID().toString(); 108 | while (id.equals(channel.getId())) id = UUID.randomUUID().toString(); 109 | String finalId = id; 110 | assertThrows(ChannelDoesNotExist.class, () -> channelService.getChannelProfile(finalId)); 111 | // test success 112 | assertEquals(channel, channelService.getChannelProfile(channel.getId().toString())); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/models/PrivateChannel.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.models; 2 | 3 | import com.joejoe2.chat.exception.BlockedException; 4 | import com.joejoe2.chat.exception.InvalidOperation; 5 | import jakarta.persistence.*; 6 | import java.time.Instant; 7 | import java.util.*; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | import org.hibernate.annotations.BatchSize; 11 | 12 | @Data 13 | @NoArgsConstructor 14 | @Entity 15 | @BatchSize(size = 128) 16 | @Table( 17 | name = "private_channel", 18 | indexes = {@Index(columnList = "uniqueUserIds"), @Index(columnList = "updateAt DESC")}) 19 | public class PrivateChannel extends TimeStampBase { 20 | @Version 21 | @Column(nullable = false, columnDefinition = "TIMESTAMP DEFAULT now()") 22 | private Instant version; 23 | 24 | @ManyToMany 25 | @BatchSize(size = 128) // for each PrivateChannels->getMembers 26 | @JoinTable( 27 | name = "private_channels_users", 28 | joinColumns = {@JoinColumn(name = "private_channel_id", nullable = false)}, 29 | inverseJoinColumns = {@JoinColumn(name = "user_id", nullable = false)}) 30 | private Set members; 31 | 32 | @ManyToMany 33 | @BatchSize(size = 128) 34 | @JoinTable( 35 | name = "private_channels_blockedBy", 36 | joinColumns = {@JoinColumn(name = "private_channel_id", nullable = false)}, 37 | inverseJoinColumns = {@JoinColumn(name = "user_id", nullable = false)}) 38 | private Set blockedBy = new HashSet<>(); 39 | 40 | @OneToMany(cascade = CascadeType.ALL, mappedBy = "channel", orphanRemoval = true) 41 | private List messages; 42 | 43 | @OneToOne(fetch = FetchType.LAZY) 44 | @JoinColumn 45 | private PrivateMessage lastMessage; 46 | 47 | public PrivateChannel(Set members) { 48 | this.members = members; 49 | } 50 | 51 | // concat two user ids in sorted to prevent duplicate channel between them 52 | @Column(unique = true, nullable = false, updatable = false) 53 | private String uniqueUserIds; 54 | 55 | @PrePersist 56 | void prePersist() { 57 | // check 58 | checkNumOfMembers(); 59 | // pre-process before save 60 | calculateUniqueByUserIds(); 61 | } 62 | 63 | void checkNumOfMembers() { 64 | if (getMembers().size() != 2) 65 | throw new RuntimeException("PrivateChannel must contain 2 members !"); 66 | } 67 | 68 | void calculateUniqueByUserIds() { 69 | List ids = 70 | members.stream() 71 | .sorted(Comparator.comparing(User::getId)) 72 | .map(user -> user.getId().toString()) 73 | .toList(); 74 | StringBuilder uniqueIds = new StringBuilder(); 75 | for (String id : ids) uniqueIds.append(id); 76 | this.uniqueUserIds = uniqueIds.toString(); 77 | } 78 | 79 | public User anotherMember(User member) { 80 | return getMembers().stream().filter(u -> !member.getId().equals(u.getId())).findFirst().get(); 81 | } 82 | 83 | /** 84 | * block or unblock the user 85 | * 86 | * @param user target user, cannot {@link #addMessage} until unblocked 87 | * @param isBlock block or unblock 88 | */ 89 | public void block(User user, boolean isBlock) { 90 | if (isBlock) { 91 | blockedBy.add(anotherMember(user)); 92 | } else { 93 | blockedBy.remove(anotherMember(user)); 94 | } 95 | } 96 | 97 | /** whether the user is blocked by the other member */ 98 | public boolean isBlocked(User user) { 99 | return blockedBy.contains(anotherMember(user)); 100 | } 101 | 102 | public void addMessage(User from, String message) throws InvalidOperation, BlockedException { 103 | if (!members.contains(from)) 104 | throw new InvalidOperation("user is not in members of the channel !"); 105 | if (isBlocked(from)) throw new BlockedException("user has been blocked !"); 106 | PrivateMessage privateMessage = new PrivateMessage(this, from, anotherMember(from), message); 107 | messages.add(privateMessage); 108 | lastMessage = privateMessage; 109 | } 110 | 111 | @Override 112 | public boolean equals(Object o) { 113 | if (this == o) return true; 114 | if (!(o instanceof PrivateChannel)) return false; 115 | PrivateChannel channel = (PrivateChannel) o; 116 | return Objects.equals(id, channel.id) && Objects.equals(uniqueUserIds, channel.uniqueUserIds); 117 | } 118 | 119 | @Override 120 | public int hashCode() { 121 | return Objects.hash(id, uniqueUserIds); 122 | } 123 | 124 | @Override 125 | public String toString() { 126 | return "PrivateChannel{" 127 | + "id=" 128 | + id 129 | + ", members=" 130 | + members 131 | + ", lastMessage=" 132 | + lastMessage 133 | + ", createAt=" 134 | + createAt 135 | + ", updateAt=" 136 | + updateAt 137 | + '}'; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/models/GroupMessage.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.models; 2 | 3 | import jakarta.persistence.*; 4 | import java.time.Instant; 5 | import java.util.Objects; 6 | import lombok.*; 7 | import org.hibernate.annotations.BatchSize; 8 | import org.hibernate.annotations.OnDelete; 9 | import org.hibernate.annotations.OnDeleteAction; 10 | 11 | @Builder 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | @Data 15 | @Entity 16 | @Table( 17 | name = "group_message", 18 | indexes = { 19 | @Index(columnList = "from_id"), 20 | @Index(columnList = "channel_id"), 21 | @Index(columnList = "updateAt DESC") 22 | }) 23 | @BatchSize(size = 32) 24 | public class GroupMessage extends TimeStampBase { 25 | @Version 26 | @Column(nullable = false, columnDefinition = "TIMESTAMP DEFAULT now()") 27 | private Instant version; 28 | 29 | @ManyToOne(optional = false, fetch = FetchType.LAZY) 30 | @OnDelete(action = OnDeleteAction.CASCADE) 31 | private GroupChannel channel; 32 | 33 | @Column(length = 32, nullable = false) 34 | @Enumerated(EnumType.STRING) 35 | private MessageType messageType = MessageType.MESSAGE; // code level default 36 | 37 | @ManyToOne(optional = false, fetch = FetchType.LAZY) 38 | User from; 39 | 40 | @Column(columnDefinition = "TEXT", nullable = false) 41 | private String content; 42 | 43 | public GroupMessage(GroupChannel channel, User from, String content) { 44 | this.channel = channel; 45 | this.from = from; 46 | this.content = content; 47 | } 48 | 49 | /** generate a message for someone inviting another to join the GroupChannel */ 50 | public static GroupMessage inviteMessage(GroupChannel channel, User inviter, User invitee) { 51 | GroupMessage message = new GroupMessage(); 52 | message.channel = channel; 53 | message.from = inviter; 54 | message.content = 55 | "{\"id\":\"%s\", \"username\":\"%s\"}" 56 | .formatted(invitee.getId().toString(), invitee.getUserName()); 57 | message.messageType = MessageType.INVITATION; 58 | return message; 59 | } 60 | 61 | /** generate a message for someone joining the GroupChannel */ 62 | public static GroupMessage joinMessage(GroupChannel channel, User joiner) { 63 | GroupMessage message = new GroupMessage(); 64 | message.channel = channel; 65 | message.from = joiner; 66 | message.content = 67 | "{\"id\":\"%s\", \"username\":\"%s\"}" 68 | .formatted(joiner.getId().toString(), joiner.getUserName()); 69 | message.messageType = MessageType.JOIN; 70 | return message; 71 | } 72 | 73 | /** generate a message for someone leaving or kicked off from the GroupChannel */ 74 | public static GroupMessage leaveMessage(GroupChannel channel, User actor, User subject) { 75 | GroupMessage message = new GroupMessage(); 76 | message.channel = channel; 77 | message.from = actor; 78 | message.content = 79 | "{\"id\":\"%s\", \"username\":\"%s\"}" 80 | .formatted(subject.getId().toString(), subject.getUserName()); 81 | message.messageType = MessageType.LEAVE; 82 | return message; 83 | } 84 | 85 | private static GroupMessage banOrUnBanMessageFormat( 86 | GroupChannel channel, User actor, User subject) { 87 | GroupMessage message = new GroupMessage(); 88 | message.channel = channel; 89 | message.from = actor; 90 | message.content = 91 | "{\"id\":\"%s\", \"username\":\"%s\"}" 92 | .formatted(subject.getId().toString(), subject.getUserName()); 93 | return message; 94 | } 95 | 96 | /** generate a message for admin banning an user */ 97 | public static GroupMessage banMessage(GroupChannel channel, User actor, User subject) { 98 | GroupMessage message = banOrUnBanMessageFormat(channel, actor, subject); 99 | message.setMessageType(MessageType.BAN); 100 | return message; 101 | } 102 | 103 | /** generate a message for admin unbanning an user */ 104 | public static GroupMessage unbanMessage(GroupChannel channel, User actor, User subject) { 105 | GroupMessage message = banOrUnBanMessageFormat(channel, actor, subject); 106 | message.setMessageType(MessageType.UNBAN); 107 | return message; 108 | } 109 | 110 | @Override 111 | public boolean equals(Object o) { 112 | if (this == o) return true; 113 | if (!(o instanceof GroupChannel)) return false; 114 | GroupChannel that = (GroupChannel) o; 115 | return Objects.equals(id, that.id); 116 | } 117 | 118 | @Override 119 | public int hashCode() { 120 | return Objects.hash(id); 121 | } 122 | 123 | @Override 124 | public String toString() { 125 | return "PrivateMessage{" 126 | + "id=" 127 | + id 128 | + ", channel=" 129 | + channel 130 | + ", messageType=" 131 | + messageType 132 | + ", from=" 133 | + from 134 | + ", content='" 135 | + content 136 | + '\'' 137 | + ", createAt=" 138 | + createAt 139 | + ", updateAt=" 140 | + updateAt 141 | + '}'; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/main/java/com/joejoe2/chat/service/message/PublicMessageServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.joejoe2.chat.service.message; 2 | 3 | import com.joejoe2.chat.data.SliceList; 4 | import com.joejoe2.chat.data.message.PublicMessageDto; 5 | import com.joejoe2.chat.exception.ChannelDoesNotExist; 6 | import com.joejoe2.chat.exception.UserDoesNotExist; 7 | import com.joejoe2.chat.models.MessageType; 8 | import com.joejoe2.chat.models.PublicChannel; 9 | import com.joejoe2.chat.models.PublicMessage; 10 | import com.joejoe2.chat.models.User; 11 | import com.joejoe2.chat.repository.channel.PublicChannelRepository; 12 | import com.joejoe2.chat.repository.message.PublicMessageRepository; 13 | import com.joejoe2.chat.service.nats.NatsService; 14 | import com.joejoe2.chat.service.user.UserService; 15 | import com.joejoe2.chat.utils.ChannelSubject; 16 | import com.joejoe2.chat.validation.validator.MessageValidator; 17 | import com.joejoe2.chat.validation.validator.PageRequestValidator; 18 | import com.joejoe2.chat.validation.validator.UUIDValidator; 19 | import java.time.Instant; 20 | import java.util.Comparator; 21 | import org.springframework.data.domain.PageRequest; 22 | import org.springframework.data.domain.Slice; 23 | import org.springframework.scheduling.annotation.Async; 24 | import org.springframework.stereotype.Service; 25 | import org.springframework.transaction.annotation.Transactional; 26 | 27 | @Service 28 | public class PublicMessageServiceImpl implements PublicMessageService { 29 | private final UserService userService; 30 | private final PublicChannelRepository channelRepository; 31 | private final PublicMessageRepository messageRepository; 32 | private final NatsService natsService; 33 | 34 | private final UUIDValidator uuidValidator = UUIDValidator.getInstance(); 35 | private final MessageValidator messageValidator = MessageValidator.getInstance(); 36 | 37 | private final PageRequestValidator pageValidator = PageRequestValidator.getInstance(); 38 | 39 | public PublicMessageServiceImpl( 40 | UserService userService, 41 | PublicChannelRepository channelRepository, 42 | PublicMessageRepository messageRepository, 43 | NatsService natsService) { 44 | this.userService = userService; 45 | this.channelRepository = channelRepository; 46 | this.messageRepository = messageRepository; 47 | this.natsService = natsService; 48 | } 49 | 50 | private PublicChannel getChannelById(String channelId) throws ChannelDoesNotExist { 51 | return channelRepository 52 | .findById(uuidValidator.validate(channelId)) 53 | .orElseThrow( 54 | () -> 55 | new ChannelDoesNotExist( 56 | "channel with id=%s does not exist !".formatted(channelId))); 57 | } 58 | 59 | @Override 60 | @Transactional(rollbackFor = Exception.class) 61 | public PublicMessageDto createMessage(String fromUserId, String channelId, String message) 62 | throws UserDoesNotExist, ChannelDoesNotExist { 63 | User user = userService.getUserById(fromUserId); 64 | PublicChannel channel = getChannelById(channelId); 65 | 66 | PublicMessage publicMessage = 67 | PublicMessage.builder() 68 | .from(user) 69 | .channel(channel) 70 | .messageType(MessageType.MESSAGE) 71 | .content(messageValidator.validate(message)) 72 | .build(); 73 | messageRepository.save(publicMessage); 74 | messageRepository.flush(); 75 | return new PublicMessageDto(publicMessage); 76 | } 77 | 78 | @Async("asyncExecutor") 79 | @Override 80 | public void deliverMessage(PublicMessageDto message) { 81 | natsService.publish( 82 | ChannelSubject.publicChannelSubject(message.getChannel().toString()), message); 83 | } 84 | 85 | @Override 86 | @Transactional(readOnly = true) 87 | public SliceList getAllMessages( 88 | String channelId, com.joejoe2.chat.data.PageRequest pageRequest) throws ChannelDoesNotExist { 89 | PageRequest paging = pageValidator.validate(pageRequest); 90 | PublicChannel channel = getChannelById(channelId); 91 | 92 | Slice slice = messageRepository.findAllByChannel(channel.getId(), paging); 93 | return new SliceList<>( 94 | slice.getNumber(), 95 | slice.getSize(), 96 | slice.getContent().stream() 97 | .sorted(Comparator.comparing(PublicMessage::getUpdateAt)) 98 | .map(PublicMessageDto::new) 99 | .toList(), 100 | slice.hasNext()); 101 | } 102 | 103 | @Override 104 | @Transactional(readOnly = true) 105 | public SliceList getAllMessages( 106 | String channelId, Instant since, com.joejoe2.chat.data.PageRequest pageRequest) 107 | throws ChannelDoesNotExist { 108 | if (since == null) throw new IllegalArgumentException("since cannot be null !"); 109 | PageRequest paging = pageValidator.validate(pageRequest); 110 | PublicChannel channel = getChannelById(channelId); 111 | 112 | Slice slice = 113 | messageRepository.findAllByChannelSince(channel.getId(), since, paging); 114 | return new SliceList<>( 115 | slice.getNumber(), 116 | slice.getSize(), 117 | slice.getContent().stream() 118 | .sorted(Comparator.comparing(PublicMessage::getUpdateAt)) 119 | .map(PublicMessageDto::new) 120 | .toList(), 121 | slice.hasNext()); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /.mvn/wrapper/MavenWrapperDownloader.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2007-present the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import java.net.*; 17 | import java.io.*; 18 | import java.nio.channels.*; 19 | import java.util.Properties; 20 | 21 | public class MavenWrapperDownloader { 22 | 23 | private static final String WRAPPER_VERSION = "0.5.6"; 24 | /** 25 | * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. 26 | */ 27 | private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" 28 | + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; 29 | 30 | /** 31 | * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to 32 | * use instead of the default one. 33 | */ 34 | private static final String MAVEN_WRAPPER_PROPERTIES_PATH = 35 | ".mvn/wrapper/maven-wrapper.properties"; 36 | 37 | /** 38 | * Path where the maven-wrapper.jar will be saved to. 39 | */ 40 | private static final String MAVEN_WRAPPER_JAR_PATH = 41 | ".mvn/wrapper/maven-wrapper.jar"; 42 | 43 | /** 44 | * Name of the property which should be used to override the default download url for the wrapper. 45 | */ 46 | private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; 47 | 48 | public static void main(String args[]) { 49 | System.out.println("- Downloader started"); 50 | File baseDirectory = new File(args[0]); 51 | System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); 52 | 53 | // If the maven-wrapper.properties exists, read it and check if it contains a custom 54 | // wrapperUrl parameter. 55 | File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); 56 | String url = DEFAULT_DOWNLOAD_URL; 57 | if(mavenWrapperPropertyFile.exists()) { 58 | FileInputStream mavenWrapperPropertyFileInputStream = null; 59 | try { 60 | mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); 61 | Properties mavenWrapperProperties = new Properties(); 62 | mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); 63 | url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); 64 | } catch (IOException e) { 65 | System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); 66 | } finally { 67 | try { 68 | if(mavenWrapperPropertyFileInputStream != null) { 69 | mavenWrapperPropertyFileInputStream.close(); 70 | } 71 | } catch (IOException e) { 72 | // Ignore ... 73 | } 74 | } 75 | } 76 | System.out.println("- Downloading from: " + url); 77 | 78 | File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); 79 | if(!outputFile.getParentFile().exists()) { 80 | if(!outputFile.getParentFile().mkdirs()) { 81 | System.out.println( 82 | "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); 83 | } 84 | } 85 | System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); 86 | try { 87 | downloadFileFromURL(url, outputFile); 88 | System.out.println("Done"); 89 | System.exit(0); 90 | } catch (Throwable e) { 91 | System.out.println("- Error downloading"); 92 | e.printStackTrace(); 93 | System.exit(1); 94 | } 95 | } 96 | 97 | private static void downloadFileFromURL(String urlString, File destination) throws Exception { 98 | if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { 99 | String username = System.getenv("MVNW_USERNAME"); 100 | char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); 101 | Authenticator.setDefault(new Authenticator() { 102 | @Override 103 | protected PasswordAuthentication getPasswordAuthentication() { 104 | return new PasswordAuthentication(username, password); 105 | } 106 | }); 107 | } 108 | URL website = new URL(urlString); 109 | ReadableByteChannel rbc; 110 | rbc = Channels.newChannel(website.openStream()); 111 | FileOutputStream fos = new FileOutputStream(destination); 112 | fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); 113 | fos.close(); 114 | rbc.close(); 115 | } 116 | 117 | } 118 | --------------------------------------------------------------------------------