From 5a52f28678fe8e7e906bf027c77f250100856916 Mon Sep 17 00:00:00 2001 From: AphexSign <83533118+AphexSign@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:48:16 +0300 Subject: [PATCH 1/2] Ylab-001: init commit for chat --- docker-compose.yml | 53 ++++ .../dashboards/chat-app-dashboard.json | 49 ++++ grafana/provisioning/dashboards/dashboard.yml | 11 + .../provisioning/datasources/datasource.yml | 10 + pom.xml | 180 ++++++++++++++ prometheus.yml | 13 + .../java/io/ylab/chat/ChatApplication.java | 24 ++ .../io/ylab/chat/aop/ChatGuardAspect.java | 150 +++++++++++ .../io/ylab/chat/aop/DegradationAspect.java | 124 ++++++++++ .../ylab/chat/aop/annotation/Idempotent.java | 24 ++ .../ylab/chat/aop/annotation/RateLimited.java | 22 ++ .../chat/aop/annotation/WithUserContext.java | 22 ++ .../chat/concurrency/MessageIdRegistry.java | 73 ++++++ .../chat/concurrency/OnlineUserRegistry.java | 96 ++++++++ .../java/io/ylab/chat/config/AsyncConfig.java | 40 +++ .../io/ylab/chat/config/WebSocketConfig.java | 144 +++++++++++ .../config/ratelimit/RateLimiterConfig.java | 43 ++++ .../ratelimit/RateLimiterProperties.java | 21 ++ .../ylab/chat/config/redis/RedisConfig.java | 105 ++++++++ .../chat/config/redis/RedisConstants.java | 25 ++ .../config/redis/RedisStreamInitializer.java | 38 +++ .../config/redis/StreamMessageConverter.java | 61 +++++ .../security/JwtAuthenticationFilter.java | 57 +++++ .../chat/config/security/SecurityConfig.java | 57 +++++ .../io/ylab/chat/context/UserContext.java | 47 ++++ .../java/io/ylab/chat/context/UserInfo.java | 23 ++ .../ylab/chat/controller/AuthController.java | 58 +++++ .../chat/controller/MetricsController.java | 45 ++++ .../controller/WebSocketExceptionHandler.java | 39 +++ .../java/io/ylab/chat/dto/AuthResponse.java | 23 ++ .../java/io/ylab/chat/dto/ChatMessageDto.java | 33 +++ .../java/io/ylab/chat/dto/LoginRequest.java | 22 ++ .../io/ylab/chat/dto/RegisterRequest.java | 22 ++ .../io/ylab/chat/entity/MessageEntity.java | 49 ++++ .../java/io/ylab/chat/entity/UserEntity.java | 36 +++ .../exception/RateLimitExceededException.java | 16 ++ .../chat/exception/UnauthorizedException.java | 16 ++ .../ylab/chat/metrics/OnlineUsersGauge.java | 35 +++ .../chat/repository/MessageRepository.java | 20 ++ .../ylab/chat/repository/UserRepository.java | 30 +++ .../io/ylab/chat/service/ChatService.java | 18 ++ .../chat/service/DeadLetterQueueService.java | 20 ++ .../service/MessagePersistenceService.java | 19 ++ .../io/ylab/chat/service/UserService.java | 32 +++ .../chat/service/impl/ChatServiceImpl.java | 71 ++++++ .../impl/DeadLetterQueueServiceImpl.java | 48 ++++ .../impl/MessagePersistenceServiceImpl.java | 45 ++++ .../chat/service/impl/UserServiceImpl.java | 86 +++++++ src/main/java/io/ylab/chat/util/JwtUtil.java | 174 +++++++++++++ .../java/io/ylab/chat/util/TimeProvider.java | 30 +++ .../websocket/ChatWebSocketController.java | 90 +++++++ .../websocket/WebSocketEventListener.java | 103 ++++++++ src/main/resources/application.yml | 92 +++++++ src/main/resources/static/css/styles.css | 233 ++++++++++++++++++ src/main/resources/static/index.html | 49 ++++ src/main/resources/static/js/api/auth.api.js | 83 +++++++ .../static/js/components/auth.component.js | 152 ++++++++++++ .../static/js/components/chat.component.js | 139 +++++++++++ .../static/js/components/ui.component.js | 220 +++++++++++++++++ src/main/resources/static/js/main.js | 41 +++ .../static/js/services/auth.service.js | 102 ++++++++ .../static/js/services/websocket.service.js | 143 +++++++++++ src/main/resources/static/js/utils/helpers.js | 49 ++++ .../io/ylab/chat/aop/ChatGuardAspectTest.java | 134 ++++++++++ .../ylab/chat/aop/DegradationAspectTest.java | 80 ++++++ .../concurrency/MessageIdRegistryTest.java | 58 +++++ .../concurrency/OnlineUserRegistryTest.java | 54 ++++ .../security/JwtAuthenticationFilterTest.java | 87 +++++++ .../service/impl/ChatServiceImplTest.java | 99 ++++++++ .../MessagePersistenceServiceImplTest.java | 71 ++++++ .../impl/TestCircuitBreakerConfig.java | 25 ++ .../service/impl/UserServiceImplTest.java | 112 +++++++++ .../java/io/ylab/chat/util/JwtUtilTest.java | 73 ++++++ .../websocket/WebSocketEventListenerTest.java | 147 +++++++++++ 74 files changed, 4935 insertions(+) create mode 100644 docker-compose.yml create mode 100644 grafana/provisioning/dashboards/chat-app-dashboard.json create mode 100644 grafana/provisioning/dashboards/dashboard.yml create mode 100644 grafana/provisioning/datasources/datasource.yml create mode 100644 pom.xml create mode 100644 prometheus.yml create mode 100644 src/main/java/io/ylab/chat/ChatApplication.java create mode 100644 src/main/java/io/ylab/chat/aop/ChatGuardAspect.java create mode 100644 src/main/java/io/ylab/chat/aop/DegradationAspect.java create mode 100644 src/main/java/io/ylab/chat/aop/annotation/Idempotent.java create mode 100644 src/main/java/io/ylab/chat/aop/annotation/RateLimited.java create mode 100644 src/main/java/io/ylab/chat/aop/annotation/WithUserContext.java create mode 100644 src/main/java/io/ylab/chat/concurrency/MessageIdRegistry.java create mode 100644 src/main/java/io/ylab/chat/concurrency/OnlineUserRegistry.java create mode 100644 src/main/java/io/ylab/chat/config/AsyncConfig.java create mode 100644 src/main/java/io/ylab/chat/config/WebSocketConfig.java create mode 100644 src/main/java/io/ylab/chat/config/ratelimit/RateLimiterConfig.java create mode 100644 src/main/java/io/ylab/chat/config/ratelimit/RateLimiterProperties.java create mode 100644 src/main/java/io/ylab/chat/config/redis/RedisConfig.java create mode 100644 src/main/java/io/ylab/chat/config/redis/RedisConstants.java create mode 100644 src/main/java/io/ylab/chat/config/redis/RedisStreamInitializer.java create mode 100644 src/main/java/io/ylab/chat/config/redis/StreamMessageConverter.java create mode 100644 src/main/java/io/ylab/chat/config/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/io/ylab/chat/config/security/SecurityConfig.java create mode 100644 src/main/java/io/ylab/chat/context/UserContext.java create mode 100644 src/main/java/io/ylab/chat/context/UserInfo.java create mode 100644 src/main/java/io/ylab/chat/controller/AuthController.java create mode 100644 src/main/java/io/ylab/chat/controller/MetricsController.java create mode 100644 src/main/java/io/ylab/chat/controller/WebSocketExceptionHandler.java create mode 100644 src/main/java/io/ylab/chat/dto/AuthResponse.java create mode 100644 src/main/java/io/ylab/chat/dto/ChatMessageDto.java create mode 100644 src/main/java/io/ylab/chat/dto/LoginRequest.java create mode 100644 src/main/java/io/ylab/chat/dto/RegisterRequest.java create mode 100644 src/main/java/io/ylab/chat/entity/MessageEntity.java create mode 100644 src/main/java/io/ylab/chat/entity/UserEntity.java create mode 100644 src/main/java/io/ylab/chat/exception/RateLimitExceededException.java create mode 100644 src/main/java/io/ylab/chat/exception/UnauthorizedException.java create mode 100644 src/main/java/io/ylab/chat/metrics/OnlineUsersGauge.java create mode 100644 src/main/java/io/ylab/chat/repository/MessageRepository.java create mode 100644 src/main/java/io/ylab/chat/repository/UserRepository.java create mode 100644 src/main/java/io/ylab/chat/service/ChatService.java create mode 100644 src/main/java/io/ylab/chat/service/DeadLetterQueueService.java create mode 100644 src/main/java/io/ylab/chat/service/MessagePersistenceService.java create mode 100644 src/main/java/io/ylab/chat/service/UserService.java create mode 100644 src/main/java/io/ylab/chat/service/impl/ChatServiceImpl.java create mode 100644 src/main/java/io/ylab/chat/service/impl/DeadLetterQueueServiceImpl.java create mode 100644 src/main/java/io/ylab/chat/service/impl/MessagePersistenceServiceImpl.java create mode 100644 src/main/java/io/ylab/chat/service/impl/UserServiceImpl.java create mode 100644 src/main/java/io/ylab/chat/util/JwtUtil.java create mode 100644 src/main/java/io/ylab/chat/util/TimeProvider.java create mode 100644 src/main/java/io/ylab/chat/websocket/ChatWebSocketController.java create mode 100644 src/main/java/io/ylab/chat/websocket/WebSocketEventListener.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/static/css/styles.css create mode 100644 src/main/resources/static/index.html create mode 100644 src/main/resources/static/js/api/auth.api.js create mode 100644 src/main/resources/static/js/components/auth.component.js create mode 100644 src/main/resources/static/js/components/chat.component.js create mode 100644 src/main/resources/static/js/components/ui.component.js create mode 100644 src/main/resources/static/js/main.js create mode 100644 src/main/resources/static/js/services/auth.service.js create mode 100644 src/main/resources/static/js/services/websocket.service.js create mode 100644 src/main/resources/static/js/utils/helpers.js create mode 100644 src/test/java/io/ylab/chat/aop/ChatGuardAspectTest.java create mode 100644 src/test/java/io/ylab/chat/aop/DegradationAspectTest.java create mode 100644 src/test/java/io/ylab/chat/concurrency/MessageIdRegistryTest.java create mode 100644 src/test/java/io/ylab/chat/concurrency/OnlineUserRegistryTest.java create mode 100644 src/test/java/io/ylab/chat/config/security/JwtAuthenticationFilterTest.java create mode 100644 src/test/java/io/ylab/chat/service/impl/ChatServiceImplTest.java create mode 100644 src/test/java/io/ylab/chat/service/impl/MessagePersistenceServiceImplTest.java create mode 100644 src/test/java/io/ylab/chat/service/impl/TestCircuitBreakerConfig.java create mode 100644 src/test/java/io/ylab/chat/service/impl/UserServiceImplTest.java create mode 100644 src/test/java/io/ylab/chat/util/JwtUtilTest.java create mode 100644 src/test/java/io/ylab/chat/websocket/WebSocketEventListenerTest.java diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..84e566b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +version: '3.9' + +services: + redis: + image: redis:latest + container_name: chat-redis + ports: + - "6379:6379" + command: redis-server --save 60 1 --loglevel warning + volumes: + - redis-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + prometheus: + image: prom/prometheus:v2.53.1 + container_name: prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus-data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + restart: unless-stopped + + grafana: + image: grafana/grafana:11.4.0 + container_name: grafana + ports: + - "3000:3000" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin123 + - GF_USERS_ALLOW_SIGN_UP=false + depends_on: + - prometheus + restart: unless-stopped + +volumes: + redis-data: + prometheus-data: + grafana-data: \ No newline at end of file diff --git a/grafana/provisioning/dashboards/chat-app-dashboard.json b/grafana/provisioning/dashboards/chat-app-dashboard.json new file mode 100644 index 0000000..6e23a0a --- /dev/null +++ b/grafana/provisioning/dashboards/chat-app-dashboard.json @@ -0,0 +1,49 @@ +{ + "title": "Chat Application Dashboard", + "uid": "chat-app-dashboard", + "version": 1, + "time": { "from": "now-30m", "to": "now" }, + "timepicker": { "refresh_intervals": ["5s","10s","30s","1m"] }, + "panels": [ + { + "title": "Active WebSocket Connections", + "type": "gauge", + "targets": [{ "expr": "spring_websocket_session_active", "legendFormat": "Active Sessions" }] + }, + { + "title": "Messages Sent per Second", + "type": "timeseries", + "targets": [{ "expr": "rate(spring_websocket_message_sent_count[30s])", "legendFormat": "Messages/sec" }] + }, + { + "title": "Persistence saveAsync - Latency", + "type": "timeseries", + "targets": [{ "expr": "rate(persistence_saveAsync_seconds_sum[30s]) / rate(persistence_saveAsync_seconds_count[30s])", "legendFormat": "Avg Latency" }] + }, + { + "title": "Persistence saveAsync - Count", + "type": "stat", + "targets": [{ "expr": "persistence_saveAsync_seconds_count" }] + }, + { + "title": "DLQ Push Count", + "type": "stat", + "targets": [{ "expr": "dlq_pushFailedMessage_seconds_count" }] + }, + { + "title": "Circuit Breaker State", + "type": "stat", + "targets": [{ "expr": "resilience4j_circuitbreaker_state{name=\"messagePersistence\"}" }] + }, + { + "title": "Circuit Breaker Failure Rate", + "type": "gauge", + "targets": [{ "expr": "resilience4j_circuitbreaker_failure_rate{name=\"messagePersistence\"}" }] + }, + { + "title": "Online Users (Gauge)", + "type": "gauge", + "targets": [{ "expr": "websocket_online_users" }] + } + ] +} \ No newline at end of file diff --git a/grafana/provisioning/dashboards/dashboard.yml b/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 0000000..c239650 --- /dev/null +++ b/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: 'Chat App Dashboards' + orgId: 1 + folder: 'Chat Application' + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards \ No newline at end of file diff --git a/grafana/provisioning/datasources/datasource.yml b/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 0000000..346f82d --- /dev/null +++ b/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + jsonData: + timeInterval: 10s \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..d198ab8 --- /dev/null +++ b/pom.xml @@ -0,0 +1,180 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + io.ylab + chat-app + 1.0.0 + Real-Time Chat Application + WebSocket Chat with AOP, Concurrency, JWT and Resilience + + + 17 + 0.12.6 + 2.3.0 + 8.10.1 + 1.19.8 + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-aop + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + io.zipkin.reporter2 + zipkin-reporter-brave + + + + + io.github.resilience4j + resilience4j-spring-boot3 + ${resilience4j.version} + + + io.github.resilience4j + resilience4j-micrometer + ${resilience4j.version} + + + + com.h2database + h2 + runtime + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + com.bucket4j + bucket4j-core + ${bucket4j.version} + + + + com.bucket4j + bucket4j-redis + ${bucket4j.version} + + + io.lettuce + lettuce-core + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + \ No newline at end of file diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..8aaab7b --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,13 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'chat-app' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['host.docker.internal:8080'] + + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/ChatApplication.java b/src/main/java/io/ylab/chat/ChatApplication.java new file mode 100644 index 0000000..eb01549 --- /dev/null +++ b/src/main/java/io/ylab/chat/ChatApplication.java @@ -0,0 +1,24 @@ +package io.ylab.chat; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.scheduling.annotation.EnableAsync; + +/** + * Main entry point for the Chat Application. + */ +@SpringBootApplication +@EnableAspectJAutoProxy +@EnableAsync +public class ChatApplication { + + /** + * Launches the Spring Boot chat application. + * + * @param args command-line arguments passed to the application + */ + public static void main(String[] args) { + SpringApplication.run(ChatApplication.class, args); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/aop/ChatGuardAspect.java b/src/main/java/io/ylab/chat/aop/ChatGuardAspect.java new file mode 100644 index 0000000..32895e3 --- /dev/null +++ b/src/main/java/io/ylab/chat/aop/ChatGuardAspect.java @@ -0,0 +1,150 @@ +package io.ylab.chat.aop; + +import io.github.bucket4j.Bucket; +import io.github.bucket4j.BucketConfiguration; +import io.github.bucket4j.ConsumptionProbe; +import io.github.bucket4j.distributed.proxy.ProxyManager; +import io.ylab.chat.aop.annotation.Idempotent; +import io.ylab.chat.aop.annotation.RateLimited; +import io.ylab.chat.aop.annotation.WithUserContext; +import io.ylab.chat.concurrency.MessageIdRegistry; +import io.ylab.chat.context.UserContext; +import io.ylab.chat.context.UserInfo; +import io.ylab.chat.dto.ChatMessageDto; +import io.ylab.chat.exception.RateLimitExceededException; +import io.ylab.chat.exception.UnauthorizedException; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +/** + * Aspect that provides cross-cutting concerns for chat message processing. + */ +@Slf4j +@Aspect +@Component +@Order(10) +@RequiredArgsConstructor +public class ChatGuardAspect { + + private final ProxyManager bucketProxyManager; + private final Supplier bucketConfigurationSupplier; + private final MessageIdRegistry messageIdRegistry; + + /** + * Intercepts methods annotated with {@link WithUserContext}. Retrieves the currently + * authenticated user from the {@link SecurityContextHolder} and stores the username in + * {@link UserContext}. After the method execution, the user context is cleared. + * + * @param jp the proceeding join point for the intercepted method + * @return the result of the method invocation + * @throws UnauthorizedException if no authentication is found, the user is not authenticated, + * or the principal is the anonymous user + * @throws Throwable if the intercepted method throws an exception + */ + @Around("@annotation(io.ylab.chat.aop.annotation.WithUserContext)") + public Object withUserContext(ProceedingJoinPoint jp) throws Throwable { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals( + auth.getPrincipal())) { + throw new UnauthorizedException("User not authenticated"); + } + + String username = auth.getName(); + UserContext.setUserInfo(UserInfo.builder().username(username).build()); + + try { + return jp.proceed(); + } finally { + UserContext.clear(); + log.debug("User context cleared for {}", username); + } + } + + /** + * Intercepts methods annotated with {@link Idempotent}. + *

+ * Ensures that the same message (identified by {@code messageId}) is processed only once. If + * the first argument of the intercepted method is a {@link ChatMessageDto}, its message ID is + * validated. A missing or empty message ID is generated automatically. The + * {@link MessageIdRegistry} is consulted to check if the message has already been processed. + * + * @param jp the proceeding join point for the intercepted method + * @return the result of the method invocation, or {@code null} if the message is a duplicate + * @throws Throwable if the intercepted method throws an exception + */ + @Around("@annotation(io.ylab.chat.aop.annotation.Idempotent)") + public Object idempotent(ProceedingJoinPoint jp) throws Throwable { + Object[] args = jp.getArgs(); + if (args.length == 0 || !(args[0] instanceof ChatMessageDto dto)) { + return jp.proceed(); + } + + String messageId = dto.getMessageId(); + if (messageId == null || messageId.trim().isEmpty()) { + messageId = UUID.randomUUID().toString(); + dto.setMessageId(messageId); + } + + if (!messageIdRegistry.markAsProcessed(messageId)) { + log.warn("Duplicate message ignored: {}", messageId); + return null; + } + return jp.proceed(); + } + + /** + * Intercepts methods annotated with {@link RateLimited}. Applies rate limiting per user based + * on a token bucket algorithm. The bucket key is derived from the current username (or + * "anonymous" if none is set). If a token is successfully consumed, the method proceeds; + * otherwise, a {@link ResponseStatusException} with HTTP 429 (Too Many Requests) is thrown. + * + * @param jp the proceeding join point for the intercepted method + * @return the result of the method invocation if rate limit is not exceeded + * @throws ResponseStatusException with {@code TOO_MANY_REQUESTS} when the rate limit is + * exceeded, including the recommended retry-after interval + * @throws Throwable if the intercepted method throws an exception + */ + @Around("@annotation(io.ylab.chat.aop.annotation.RateLimited)") + public Object rateLimited(ProceedingJoinPoint jp) throws Throwable { + String username = UserContext.getCurrentUsername(); + if (username == null) { + username = "anonymous"; + } + + String bucketKey = "rate_limit:chat:message:" + username; + byte[] keyBytes = bucketKey.getBytes(StandardCharsets.UTF_8); + + Bucket bucket = bucketProxyManager.builder() + .build(keyBytes, bucketConfigurationSupplier.get()); + + ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1); + + if (probe.isConsumed()) { + log.debug("Allowed for {}, remaining: {}", username, probe.getRemainingTokens()); + return jp.proceed(); + } + + long waitSeconds = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()); + + log.warn("Rate limit exceeded for {}, retry after {}s", username, waitSeconds); + + throw new ResponseStatusException( + HttpStatus.TOO_MANY_REQUESTS, + "Too many messages. Retry after " + waitSeconds + " seconds", + new RateLimitExceededException("Rate limit exceeded") + ); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/aop/DegradationAspect.java b/src/main/java/io/ylab/chat/aop/DegradationAspect.java new file mode 100644 index 0000000..43e721e --- /dev/null +++ b/src/main/java/io/ylab/chat/aop/DegradationAspect.java @@ -0,0 +1,124 @@ +package io.ylab.chat.aop; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.annotation.Order; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Aspect for handling system degradation in case of repeated failures in message persistence. + */ +@Slf4j +@Aspect +@Order(4) +@Component +public class DegradationAspect { + + /** + * Flag indicating whether the system is in degraded mode. + */ + private final AtomicBoolean degradedMode = new AtomicBoolean(false); + + /** + * Counter for tracking consecutive failures in message persistence. + */ + private final AtomicInteger failureCount = new AtomicInteger(0); + + /** + * The threshold for consecutive failures before entering degraded mode. + */ + private static final int FAILURE_THRESHOLD = 5; + + /** + * Around advice for handling failures in the `saveAsync` method of + * {@code MessagePersistenceService}. + * + * @param joinPoint the {@link ProceedingJoinPoint} representing the intercepted method + * @return the result of the method execution, or {@code null} if persistence is skipped + * @throws Throwable if an error occurs during method execution + */ + @Around("execution(* io.ylab.chat.service.MessagePersistenceService.saveAsync(..))") + public Object handlePersistenceFailure(ProceedingJoinPoint joinPoint) throws Throwable { + if (degradedMode.get()) { + log.warn("System in degraded mode - skipping persistence"); + return null; + } + try { + Object result = joinPoint.proceed(); + + if (failureCount.get() > 0) { + failureCount.set(0); + log.info("Persistence recovered - failure count reset"); + } + + return result; + + } catch (RejectedExecutionException e) { + handleFailure("Thread pool exhausted", e); + return null; + + } catch (Exception e) { + handleFailure("Database persistence failed", e); + return null; + } + } + + @Scheduled(fixedDelay = 60000) + public void attemptRecovery() { + if (degradedMode.get()) { + try { + if (checkDatabaseConnection()) { + recover(); + log.info("Automatic recovery from degraded mode succeeded"); + } + } catch (Exception e) { + log.debug("Recovery check failed, staying in degraded mode"); + } + } + } + + private boolean checkDatabaseConnection() { + return true; + } + + /** + * Handles a failure in message persistence. + * + * @param reason the reason for the failure + * @param e the exception that caused the failure + */ + private void handleFailure(String reason, Exception e) { + int failures = failureCount.incrementAndGet(); + + log.error("{} (failure {}/{}): {}", reason, failures, FAILURE_THRESHOLD, e.getMessage()); + + if (failures >= FAILURE_THRESHOLD && !degradedMode.get()) { + degradedMode.set(true); + log.error("ENTERING DEGRADED MODE - Chat continues without persistence"); + } + } + + /** + * Checks if the system is currently in degraded mode. + * + * @return {@code true} if the system is in degraded mode, {@code false} otherwise + */ + public boolean isDegraded() { + return degradedMode.get(); + } + + /** + * Recovers the system from degraded mode. + */ + public void recover() { + degradedMode.set(false); + failureCount.set(0); + log.info("System recovered from degraded mode"); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/aop/annotation/Idempotent.java b/src/main/java/io/ylab/chat/aop/annotation/Idempotent.java new file mode 100644 index 0000000..bc89dea --- /dev/null +++ b/src/main/java/io/ylab/chat/aop/annotation/Idempotent.java @@ -0,0 +1,24 @@ +package io.ylab.chat.aop.annotation; + +import io.ylab.chat.aop.ChatGuardAspect; +import io.ylab.chat.concurrency.MessageIdRegistry; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates that a method should be idempotent with respect to message processing. When this + * annotation is applied, the {@link ChatGuardAspect} ensures that the same message (identified by + * its {@code messageId}) is processed only once. If the method's first parameter is a + * {@code ChatMessageDto}, the aspect automatically generates a {@code messageId} if missing and + * uses {@link MessageIdRegistry} to detect duplicates. Duplicate messages are silently ignored (the + * method is not invoked). + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Idempotent { + +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/aop/annotation/RateLimited.java b/src/main/java/io/ylab/chat/aop/annotation/RateLimited.java new file mode 100644 index 0000000..8e33be8 --- /dev/null +++ b/src/main/java/io/ylab/chat/aop/annotation/RateLimited.java @@ -0,0 +1,22 @@ +package io.ylab.chat.aop.annotation; + +import io.ylab.chat.aop.ChatGuardAspect; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Applies rate limiting to the annotated method. Methods marked with this annotation are protected + * by a token bucket rate limiter, managed by the {@link ChatGuardAspect}. The rate limit is applied + * per user (based on {@code UserContext.getCurrentUsername()}) using a configurable bucket from + * {@code BucketConfigurationSupplier}. If the rate limit is exceeded, the method invocation is + * blocked and an HTTP 429 (Too Many Requests) response is thrown. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RateLimited { + +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/aop/annotation/WithUserContext.java b/src/main/java/io/ylab/chat/aop/annotation/WithUserContext.java new file mode 100644 index 0000000..9f7be98 --- /dev/null +++ b/src/main/java/io/ylab/chat/aop/annotation/WithUserContext.java @@ -0,0 +1,22 @@ +package io.ylab.chat.aop.annotation; + +import io.ylab.chat.aop.ChatGuardAspect; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Establishes user context before method execution and clears it afterwards. When this annotation + * is applied, the {@link ChatGuardAspect} extracts the currently authenticated user from + * {@code SecurityContextHolder}, validates that the user is authenticated (not anonymous), and sets + * the username into {@code UserContext}. After the method completes, the user context is cleared to + * avoid leakage across threads. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface WithUserContext { + +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/concurrency/MessageIdRegistry.java b/src/main/java/io/ylab/chat/concurrency/MessageIdRegistry.java new file mode 100644 index 0000000..eaef1d3 --- /dev/null +++ b/src/main/java/io/ylab/chat/concurrency/MessageIdRegistry.java @@ -0,0 +1,73 @@ +package io.ylab.chat.concurrency; + +import org.springframework.stereotype.Component; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Component that tracks processed message IDs to prevent duplicate processing. + */ +@Component +public class MessageIdRegistry { + + /** + * Time-to-live for message IDs in milliseconds (5 minutes). + */ + private static final long TTL_MS = 300_000; + + /** + * Map storing processed message IDs and their processing timestamps. + */ + private final Map processedMessages = new ConcurrentHashMap<>(); + /** + * Scheduled executor service for periodic cleanup of expired message IDs. + */ + private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor(); + + /** + * Constructs a new {@code MessageIdRegistry} and starts the periodic cleanup task. + */ + public MessageIdRegistry() { + cleanupExecutor.scheduleAtFixedRate(this::cleanup, 1, 1, TimeUnit.MINUTES); + } + + /** + * Checks if a message ID has already been processed. + * + * @param messageId the message ID to check + * @return {@code true} if the message ID has been processed, {@code false} otherwise + */ + public boolean isProcessed(String messageId) { + return processedMessages.containsKey(messageId); + } + + /** + * Marks a message ID as processed by recording the current timestamp. + * + * @param messageId the message ID to mark as processed + * @return {@code true} if the message ID was newly recorded, {@code false} if it was already + * present + */ + public boolean markAsProcessed(String messageId) { + Long existing = processedMessages.putIfAbsent(messageId, System.currentTimeMillis()); + return existing == null; + } + + /** + * Removes message IDs that have exceeded their time-to-live (TTL). + */ + private void cleanup() { + long now = System.currentTimeMillis(); + processedMessages.entrySet().removeIf(entry -> now - entry.getValue() > TTL_MS); + } + + /** + * Shuts down the scheduled cleanup executor. + */ + public void shutdown() { + cleanupExecutor.shutdown(); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/concurrency/OnlineUserRegistry.java b/src/main/java/io/ylab/chat/concurrency/OnlineUserRegistry.java new file mode 100644 index 0000000..3454bdb --- /dev/null +++ b/src/main/java/io/ylab/chat/concurrency/OnlineUserRegistry.java @@ -0,0 +1,96 @@ +package io.ylab.chat.concurrency; + +import lombok.*; +import org.springframework.stereotype.Component; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Component for managing the registry of online users in a thread-safe manner. + */ +@Component +public class OnlineUserRegistry { + + /** + * A thread-safe map to store online users and their session information. The key is the + * username, and the value is a {@link SessionInfo} object. + */ + private final Map onlineUsers = new ConcurrentHashMap<>(); + + /** + * Registers a user as online by storing their session information. + * + * @param username the username of the user + * @param sessionId the session ID associated with the user + */ + public void register(String username, String sessionId) { + SessionInfo info = SessionInfo.builder() + .username(username) + .sessionId(sessionId) + .connectedAt(System.currentTimeMillis()) + .build(); + onlineUsers.put(username, info); + } + + /** + * Unregisters a user, removing them from the online users registry. + * + * @param username the username of the user to unregister + */ + public void unregister(String username) { + onlineUsers.remove(username); + } + + /** + * Checks if a user is currently online. + * + * @param username the username to check + * @return {@code true} if the user is online, {@code false} otherwise + */ + public boolean isOnline(String username) { + return onlineUsers.containsKey(username); + } + + /** + * Retrieves the set of usernames of all online users. + * + * @return a {@link Set} containing the usernames of online users + */ + public Set getOnlineUsers() { + return new HashSet<>(onlineUsers.keySet()); + } + + /** + * Retrieves the total count of online users. + * + * @return the number of online users + */ + public int getOnlineCount() { + return onlineUsers.size(); + } + + /** + * Inner class representing session information for an online user. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SessionInfo { + + /** + * The username of the user. + */ + private String username; + + /** + * The session ID associated with the user. + */ + private String sessionId; + + /** + * The timestamp (in milliseconds) when the user connected. + */ + private long connectedAt; + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/config/AsyncConfig.java b/src/main/java/io/ylab/chat/config/AsyncConfig.java new file mode 100644 index 0000000..c8e1db3 --- /dev/null +++ b/src/main/java/io/ylab/chat/config/AsyncConfig.java @@ -0,0 +1,40 @@ +package io.ylab.chat.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * Configuration class for setting up asynchronous task execution. + */ +@Configuration +@Slf4j +public class AsyncConfig { + + /** + * Creates and configures a {@link ThreadPoolTaskExecutor} for chat-related asynchronous tasks. + * + * @return the configured {@link ThreadPoolTaskExecutor} instance + */ + @Bean(name = "chatExecutor") + public ThreadPoolTaskExecutor chatExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(8); + executor.setMaxPoolSize(16); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("chat-msg-"); + executor.setRejectedExecutionHandler( + new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setThreadFactory(r -> { + Thread t = new Thread(r); + t.setUncaughtExceptionHandler((thread, e) -> + log.error("Uncaught exception in thread {}: {}", thread.getName(), e.getMessage(), + e)); + return t; + }); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/config/WebSocketConfig.java b/src/main/java/io/ylab/chat/config/WebSocketConfig.java new file mode 100644 index 0000000..0b3e422 --- /dev/null +++ b/src/main/java/io/ylab/chat/config/WebSocketConfig.java @@ -0,0 +1,144 @@ +package io.ylab.chat.config; + +import io.ylab.chat.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +import java.security.Principal; +import java.util.ArrayList; + +/** + * Configuration class for setting up WebSocket messaging with Spring. + */ +@Slf4j +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final JwtUtil jwtUtil; + + /** + * Configures the message broker for WebSocket communication. + * + * @param config the {@link MessageBrokerRegistry} to configure + */ + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic", "/queue"); + config.setApplicationDestinationPrefixes("/app"); + config.setUserDestinationPrefix("/user"); + } + + /** + * Registers STOMP endpoints for WebSocket communication. + * + * @param registry the {@link StompEndpointRegistry} to configure + */ + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .setAllowedOriginPatterns("*") + .withSockJS(); + } + + private Principal authenticateFromToken(String authHeader) { + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return null; + } + String token = authHeader.substring(7); + try { + String username = jwtUtil.extractUsername(token); + if (jwtUtil.validateToken(token, username)) { + return () -> username; + } + } catch (Exception e) { + log.debug("JWT validation failed: {}", e.getMessage()); + } + return null; + } + + /** + * Configures the client inbound channel to intercept and process WebSocket messages. + * + * @param registration the {@link ChannelRegistration} to configure + */ + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(new ChannelInterceptor() { + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, + StompHeaderAccessor.class); + + log.debug("WebSocket Interceptor: Command = {}, User = {}", + accessor.getCommand(), accessor.getUser()); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + String authToken = accessor.getFirstNativeHeader("Authorization"); + Principal principal = authenticateFromToken(authToken); + + if (principal != null) { + accessor.setUser(principal); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(principal.getName(), null, + new ArrayList<>()); + SecurityContextHolder.getContext().setAuthentication(auth); + } else { + throw new RuntimeException("Invalid JWT token"); + } + } else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand()) || + StompCommand.SEND.equals(accessor.getCommand())) { + + if (accessor.getUser() == null) { + Authentication auth = SecurityContextHolder.getContext() + .getAuthentication(); + if (auth != null && auth.isAuthenticated() + && !(auth.getPrincipal() instanceof String)) { + Principal principal = () -> auth.getName(); + accessor.setUser(principal); + } + } + + if (accessor.getUser() == null) { + String authToken = accessor.getFirstNativeHeader("Authorization"); + if (authToken != null && authToken.startsWith("Bearer ")) { + String token = authToken.substring(7); + try { + String username = jwtUtil.extractUsername(token); + if (jwtUtil.validateToken(token, username)) { + Principal principal = () -> username; + accessor.setUser(principal); + UsernamePasswordAuthenticationToken authTokenObj = + new UsernamePasswordAuthenticationToken(principal, null, + new ArrayList<>()); + SecurityContextHolder.getContext() + .setAuthentication(authTokenObj); + } + } catch (Exception e) { + log.error("WebSocket {}: Invalid JWT token", accessor.getCommand(), + e); + } + } + } + } + + return message; + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/config/ratelimit/RateLimiterConfig.java b/src/main/java/io/ylab/chat/config/ratelimit/RateLimiterConfig.java new file mode 100644 index 0000000..d9f5bdf --- /dev/null +++ b/src/main/java/io/ylab/chat/config/ratelimit/RateLimiterConfig.java @@ -0,0 +1,43 @@ +package io.ylab.chat.config.ratelimit; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.BucketConfiguration; +import io.github.bucket4j.distributed.proxy.ProxyManager; +import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager; +import io.lettuce.core.RedisClient; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.codec.ByteArrayCodec; +import java.time.Duration; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +@EnableConfigurationProperties(RateLimiterProperties.class) +public class RateLimiterConfig { + + private final RedisClient redisClient; + private final RateLimiterProperties properties; + + @Bean + public ProxyManager bucketProxyManager() { + StatefulRedisConnection connection = redisClient.connect( + ByteArrayCodec.INSTANCE); + return LettuceBasedProxyManager.builderFor(connection) + .build(); + } + + @Bean + public Supplier messageBucketConfiguration() { + return () -> BucketConfiguration.builder() + .addLimit(Bandwidth.builder() + .capacity(properties.getMessages().getCapacity()) + .refillGreedy(properties.getMessages().getRefillTokens(), + Duration.ofSeconds(properties.getMessages().getRefillDurationSeconds())) + .build()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/config/ratelimit/RateLimiterProperties.java b/src/main/java/io/ylab/chat/config/ratelimit/RateLimiterProperties.java new file mode 100644 index 0000000..81245a8 --- /dev/null +++ b/src/main/java/io/ylab/chat/config/ratelimit/RateLimiterProperties.java @@ -0,0 +1,21 @@ +package io.ylab.chat.config.ratelimit; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "rate-limiter") +public class RateLimiterProperties { + + private Messages messages = new Messages(); + + @Getter + @Setter + public static class Messages { + private long capacity; + private long refillTokens; + private long refillDurationSeconds; + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/config/redis/RedisConfig.java b/src/main/java/io/ylab/chat/config/redis/RedisConfig.java new file mode 100644 index 0000000..7d0bed1 --- /dev/null +++ b/src/main/java/io/ylab/chat/config/redis/RedisConfig.java @@ -0,0 +1,105 @@ +package io.ylab.chat.config.redis; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisURI; +import io.ylab.chat.entity.MessageEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Configuration class for Redis connections and serialization. This class sets up a reactive + * Lettuce-based Redis client and two {@link RedisTemplate} beans for generic object storage and + * typed message entity storage. Connection parameters (host, port, password) are read from Spring + * environment properties with sensible defaults. + * + * @see RedisClient + * @see RedisTemplate + */ +@Slf4j +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host:localhost}") + private String redisHost; + + @Value("${spring.data.redis.port:6379}") + private int redisPort; + + @Value("${spring.data.redis.password:}") + private String redisPassword; + + /** + * Creates a Lettuce-based Redis client with the configured host, port, and optional password. + * The client is responsible for managing low-level connections to the Redis server. The + * {@code destroyMethod = "shutdown"} ensures proper resource cleanup when the application + * context is closed. + * + * @return a configured {@link RedisClient} instance + */ + @Bean(destroyMethod = "shutdown") + public RedisClient lettuceRedisClient() { + RedisURI.Builder builder = RedisURI.builder() + .withHost(redisHost) + .withPort(redisPort); + if (!redisPassword.isBlank()) { + builder.withPassword(redisPassword.toCharArray()); + } + return RedisClient.create(builder.build()); + } + + /** + * Creates a generic {@link RedisTemplate} for storing and retrieving arbitrary Java objects. + * Keys and hash keys are serialized as UTF-8 strings using {@link StringRedisSerializer}. + * Values and hash values are serialized as JSON using {@link Jackson2JsonRedisSerializer} with + * {@link Object} as the target type, allowing storage of any serializable object. This template + * is suitable for general-purpose caching or data storage where the exact value type may vary. + * + * @param factory the Redis connection factory (auto-configured by Spring Boot) + * @return a configured {@link RedisTemplate} for {@code String, Object} pairs + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>( + Object.class); + template.setValueSerializer(serializer); + template.setHashValueSerializer(serializer); + template.setStringSerializer(new StringRedisSerializer()); + return template; + } + + /** + * Creates a type-safe {@link RedisTemplate} specifically for {@link MessageEntity} objects. + *

+ * Keys and hash keys are serialized as UTF-8 strings. Values and hash values are serialized as + * JSON using a {@link Jackson2JsonRedisSerializer} parameterized with {@link MessageEntity}, + * ensuring type safety and eliminating the need for casting when working with message data. + * + * @param factory the Redis connection factory (auto-configured by Spring Boot) + * @return a configured {@link RedisTemplate} for {@code String, MessageEntity} pairs + */ + @Bean + public RedisTemplate messageEntityRedisTemplate( + RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>( + MessageEntity.class); + template.setValueSerializer(serializer); + template.setHashValueSerializer(serializer); + template.setStringSerializer(new StringRedisSerializer()); + return template; + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/config/redis/RedisConstants.java b/src/main/java/io/ylab/chat/config/redis/RedisConstants.java new file mode 100644 index 0000000..7ae2318 --- /dev/null +++ b/src/main/java/io/ylab/chat/config/redis/RedisConstants.java @@ -0,0 +1,25 @@ +package io.ylab.chat.config.redis; + +import lombok.experimental.UtilityClass; + +/** + * Central repository of Redis key and consumer group constants used throughout the chat + * application. + * + * @see org.springframework.data.redis.connection.stream.StreamRecords + * @see org.springframework.data.redis.connection.stream.Consumer + */ +@UtilityClass +public class RedisConstants { + + /** + * Redis stream key for the dead-letter queue. + */ + public static final String DEAD_LETTER_STREAM_KEY = "chat:dead-letter:stream"; + + /** + * Consumer group name for the dead-letter queue stream. + */ + public static final String DEAD_LETTER_CONSUMER_GROUP = "chat-dlq-group"; + +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/config/redis/RedisStreamInitializer.java b/src/main/java/io/ylab/chat/config/redis/RedisStreamInitializer.java new file mode 100644 index 0000000..f4d5e34 --- /dev/null +++ b/src/main/java/io/ylab/chat/config/redis/RedisStreamInitializer.java @@ -0,0 +1,38 @@ +package io.ylab.chat.config.redis; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +/** + * Initializes Redis stream infrastructure components on application startup. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisStreamInitializer { + + private final RedisTemplate redisTemplate; + + /** + * Creates the dead-letter queue consumer group in Redis. Invoked automatically after dependency + * injection is complete. If the group creation fails because the group already exists (common + * on subsequent application restarts), the exception is logged at debug level. For any other + * exception, the same debug log is written; the method does not rethrow the exception to avoid + * blocking startup. + */ + @PostConstruct + public void init() { + try { + redisTemplate.opsForStream().createGroup(RedisConstants.DEAD_LETTER_STREAM_KEY, + RedisConstants.DEAD_LETTER_CONSUMER_GROUP); + log.info("Redis Stream consumer group '{}' created for key '{}'", + RedisConstants.DEAD_LETTER_CONSUMER_GROUP, RedisConstants.DEAD_LETTER_STREAM_KEY); + } catch (Exception e) { + log.debug("Redis Stream group '{}' already exists or error: {}", + RedisConstants.DEAD_LETTER_CONSUMER_GROUP, e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/config/redis/StreamMessageConverter.java b/src/main/java/io/ylab/chat/config/redis/StreamMessageConverter.java new file mode 100644 index 0000000..8d7b4d8 --- /dev/null +++ b/src/main/java/io/ylab/chat/config/redis/StreamMessageConverter.java @@ -0,0 +1,61 @@ +package io.ylab.chat.config.redis; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.Map; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.stereotype.Component; + +/** + * Converts between Java objects and Redis Stream field maps. This component serializes a message + * payload into a {@code Map} suitable for publishing to a Redis Stream via + * {@code StreamOperations.add(...)}. Each entry in the stream record contains three fields: + */ +@Component +public class StreamMessageConverter { + + private final Jackson2JsonRedisSerializer serializer; + + /** + * Constructs a new StreamMessageConverter with the given ObjectMapper. + * + * @param objectMapper the Jackson ObjectMapper to use for JSON serialization/deserialization + */ + public StreamMessageConverter(ObjectMapper objectMapper) { + this.serializer = new Jackson2JsonRedisSerializer<>(Object.class); + this.serializer.setObjectMapper(objectMapper); + } + + /** + * Converts a payload object into a map of fields ready for Redis Stream storage. + * + * @param payload the object to serialize (must be serializable by Jackson) + * @return a mutable map with three entries (never {@code null}) + */ + public Map toFields(Object payload) { + Map fields = new HashMap<>(); + fields.put("payload", serializer.serialize(payload)); + fields.put("type", payload.getClass().getSimpleName()); + fields.put("timestamp", System.currentTimeMillis()); + return fields; + } + + /** + * Reconstructs an object from Redis Stream field map. + * + * @param fields the map of stream fields (typically from {@code StreamRecords.read(...)}) + * @param type the target class to deserialize into + * @param the type of the reconstructed object + * @return the deserialized object, or {@code null} if no payload is present + * @throws IllegalArgumentException if the payload bytes cannot be deserialized to the target + * type + */ + @SuppressWarnings("unchecked") + public T fromFields(Map fields, Class type) { + byte[] payloadBytes = (byte[]) fields.get("payload"); + if (payloadBytes == null) { + return null; + } + return (T) serializer.deserialize(payloadBytes); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/config/security/JwtAuthenticationFilter.java b/src/main/java/io/ylab/chat/config/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..6e8fd49 --- /dev/null +++ b/src/main/java/io/ylab/chat/config/security/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package io.ylab.chat.config.security; + +import io.ylab.chat.util.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Filter that intercepts HTTP requests to perform JWT token validation and authentication. + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + /** + * Filters each HTTP request to check for a valid JWT token in the "Authorization" header. + * + * @param request the incoming HTTP request + * @param response the HTTP response + * @param filterChain the filter chain to pass the request and response to the next filter + * @throws ServletException if an error occurs during filtering + * @throws IOException if an I/O error occurs during filtering + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + try { + String username = jwtUtil.extractUsername(token); + if (username != null + && SecurityContextHolder.getContext().getAuthentication() == null) { + if (jwtUtil.validateToken(token, username)) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(username, null, + new ArrayList<>()); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + } catch (Exception e) { + } + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/io/ylab/chat/config/security/SecurityConfig.java b/src/main/java/io/ylab/chat/config/security/SecurityConfig.java new file mode 100644 index 0000000..9f78adb --- /dev/null +++ b/src/main/java/io/ylab/chat/config/security/SecurityConfig.java @@ -0,0 +1,57 @@ +package io.ylab.chat.config.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * Configuration class for setting up security in the application using Spring Security. + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + /** + * Configures the security filter chain for the application. + * + * @param http the {@link HttpSecurity} object to configure + * @return the configured {@link SecurityFilterChain} + * @throws Exception if an error occurs during configuration + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/index.html", "/css/**", "/js/**", "/register", "/login", + "/ws/**", "/actuator/**", "/test/**").permitAll() + .anyRequest().authenticated() + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + /** + * Provides a {@link PasswordEncoder} bean for encoding and verifying passwords. + * + * @return a {@link BCryptPasswordEncoder} instance + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/context/UserContext.java b/src/main/java/io/ylab/chat/context/UserContext.java new file mode 100644 index 0000000..949a278 --- /dev/null +++ b/src/main/java/io/ylab/chat/context/UserContext.java @@ -0,0 +1,47 @@ +package io.ylab.chat.context; + +/** + * Utility class for storing and accessing user-related information in a thread-local context. + */ +public class UserContext { + + /** + * Thread-local storage for {@link UserInfo} associated with the current thread. + */ + private static final ThreadLocal contextHolder = new ThreadLocal<>(); + + /** + * Sets the {@link UserInfo} for the current thread. + * + * @param userInfo the user information to associate with the current thread + */ + public static void setUserInfo(UserInfo userInfo) { + contextHolder.set(userInfo); + } + + /** + * Retrieves the {@link UserInfo} associated with the current thread. + * + * @return the current thread's user information, or {@code null} if none is set + */ + public static UserInfo getUserInfo() { + return contextHolder.get(); + } + + /** + * Clears the {@link UserInfo} associated with the current thread. + */ + public static void clear() { + contextHolder.remove(); + } + + /** + * Retrieves the username of the current user from the thread-local context. + * + * @return the username if {@link UserInfo} is set; otherwise, {@code null} + */ + public static String getCurrentUsername() { + UserInfo userInfo = getUserInfo(); + return userInfo != null ? userInfo.getUsername() : null; + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/context/UserInfo.java b/src/main/java/io/ylab/chat/context/UserInfo.java new file mode 100644 index 0000000..4ec481b --- /dev/null +++ b/src/main/java/io/ylab/chat/context/UserInfo.java @@ -0,0 +1,23 @@ +package io.ylab.chat.context; + +import lombok.*; + +/** + * Data transfer object representing basic user information. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserInfo { + + /** + * The username of the user. + */ + private String username; + + /** + * The unique identifier of the user. + */ + private Long userId; +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/controller/AuthController.java b/src/main/java/io/ylab/chat/controller/AuthController.java new file mode 100644 index 0000000..5d96582 --- /dev/null +++ b/src/main/java/io/ylab/chat/controller/AuthController.java @@ -0,0 +1,58 @@ +package io.ylab.chat.controller; + +import io.ylab.chat.dto.AuthResponse; +import io.ylab.chat.dto.LoginRequest; +import io.ylab.chat.dto.RegisterRequest; +import io.ylab.chat.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * REST controller for authentication operations. + * + * @see UserService + * @see RegisterRequest + * @see LoginRequest + * @see AuthResponse + */ +@RestController +@RequiredArgsConstructor +public class AuthController { + + private final UserService userService; + + /** + * Registers a new user. + * + * @param request the registration data (must not be {@code null}) + * @return a {@link ResponseEntity} containing {@link AuthResponse} with HTTP status 200 (OK) if + * registration is successful + */ + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegisterRequest request) { + try { + AuthResponse response = userService.register(request); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } + + /** + * Authenticates an existing user. + * + * @param request the login credentials (must not be {@code null}) + * @return a {@link ResponseEntity} containing {@link AuthResponse} with HTTP status 200 (OK) if + * authentication succeeds + */ + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request) { + try { + AuthResponse response = userService.login(request); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(401).build(); + } + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/controller/MetricsController.java b/src/main/java/io/ylab/chat/controller/MetricsController.java new file mode 100644 index 0000000..ed202bc --- /dev/null +++ b/src/main/java/io/ylab/chat/controller/MetricsController.java @@ -0,0 +1,45 @@ +package io.ylab.chat.controller; + +import io.ylab.chat.aop.DegradationAspect; +import io.ylab.chat.concurrency.OnlineUserRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.util.*; + +/** + * REST controller for system metrics and operational controls. + * + * @see OnlineUserRegistry + * @see DegradationAspect + */ +@RestController +@RequestMapping("/metrics") +@RequiredArgsConstructor +public class MetricsController { + + private final OnlineUserRegistry onlineUserRegistry; + private final DegradationAspect degradationAspect; + + /** + * Manually recovers the system from degraded mode. + * + * @return a {@link ResponseEntity} with HTTP status 200 (OK) and a confirmation message + */ + @PostMapping("/recover") + public ResponseEntity recover() { + degradationAspect.recover(); + return ResponseEntity.ok("System recovered from degraded mode"); + } + + /** + * Retrieves the set of currently online users. + * + * @return a {@link ResponseEntity} containing a {@link Set} of online usernames with HTTP + * status 200 (OK) + */ + @GetMapping("/online") + public ResponseEntity> getOnlineUsers() { + return ResponseEntity.ok(onlineUserRegistry.getOnlineUsers()); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/controller/WebSocketExceptionHandler.java b/src/main/java/io/ylab/chat/controller/WebSocketExceptionHandler.java new file mode 100644 index 0000000..ea5dfbc --- /dev/null +++ b/src/main/java/io/ylab/chat/controller/WebSocketExceptionHandler.java @@ -0,0 +1,39 @@ +package io.ylab.chat.controller; + +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.simp.annotation.SendToUser; +import org.springframework.stereotype.Controller; +import org.springframework.web.server.ResponseStatusException; + +/** + * Handles exceptions thrown during WebSocket message processing. This controller advice intercepts + * exceptions raised by {@code @MessageMapping} methods and sends error responses back to the client + * via a private user queue. + */ +@Controller +public class WebSocketExceptionHandler { + + /** + * Handles {@link ResponseStatusException} thrown during message processing. + * + * @param ex the caught ResponseStatusException + * @return the error reason to be delivered to the client + */ + @MessageExceptionHandler(ResponseStatusException.class) + @SendToUser("/queue/errors") + public String handleRateLimit(ResponseStatusException ex) { + return ex.getReason(); + } + + /** + * Handles any other exception not specifically mapped. + * + * @param ex the caught exception + * @return the exception message + */ + @MessageExceptionHandler + @SendToUser("/queue/errors") + public String handleException(Exception ex) { + return ex.getMessage(); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/dto/AuthResponse.java b/src/main/java/io/ylab/chat/dto/AuthResponse.java new file mode 100644 index 0000000..a59820c --- /dev/null +++ b/src/main/java/io/ylab/chat/dto/AuthResponse.java @@ -0,0 +1,23 @@ +package io.ylab.chat.dto; + +import lombok.*; + +/** + * Data transfer object representing the response returned after successful authentication. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AuthResponse { + + /** + * The JSON Web Token (JWT) issued upon successful authentication. + */ + private String token; + + /** + * The username of the authenticated user. + */ + private String username; +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/dto/ChatMessageDto.java b/src/main/java/io/ylab/chat/dto/ChatMessageDto.java new file mode 100644 index 0000000..fd34571 --- /dev/null +++ b/src/main/java/io/ylab/chat/dto/ChatMessageDto.java @@ -0,0 +1,33 @@ +package io.ylab.chat.dto; + +import lombok.*; + +/** + * Data transfer object representing a chat message. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatMessageDto { + + /** + * The unique identifier of the message. + */ + private String messageId; + + /** + * The username or identifier of the sender of the message. + */ + private String sender; + + /** + * The text content of the message. + */ + private String text; + + /** + * The timestamp of the message represented as milliseconds since the epoch (Unix time). + */ + private Long timestamp; +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/dto/LoginRequest.java b/src/main/java/io/ylab/chat/dto/LoginRequest.java new file mode 100644 index 0000000..bfb4bd3 --- /dev/null +++ b/src/main/java/io/ylab/chat/dto/LoginRequest.java @@ -0,0 +1,22 @@ +package io.ylab.chat.dto; + +import lombok.*; + +/** + * Data transfer object representing a login request. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequest { + + /** + * The username of the user attempting to log in. + */ + private String username; + + /** + * The password of the user attempting to log in. + */ + private String password; +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/dto/RegisterRequest.java b/src/main/java/io/ylab/chat/dto/RegisterRequest.java new file mode 100644 index 0000000..68dab92 --- /dev/null +++ b/src/main/java/io/ylab/chat/dto/RegisterRequest.java @@ -0,0 +1,22 @@ +package io.ylab.chat.dto; + +import lombok.*; + +/** + * Data transfer object representing a user registration request. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RegisterRequest { + + /** + * The desired username for the new user account. + */ + private String username; + + /** + * The password for the new user account. + */ + private String password; +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/entity/MessageEntity.java b/src/main/java/io/ylab/chat/entity/MessageEntity.java new file mode 100644 index 0000000..900ff8d --- /dev/null +++ b/src/main/java/io/ylab/chat/entity/MessageEntity.java @@ -0,0 +1,49 @@ +package io.ylab.chat.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +/** + * Entity representing a chat message stored in the database. + */ +@Entity +@Setter +@Getter +@Builder +@Table(name = "messages") +@NoArgsConstructor +@AllArgsConstructor +public class MessageEntity { + + /** + * The primary key of the message entity. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * The unique message identifier. + */ + @Column(unique = true, nullable = false) + private String messageId; + + /** + * The username or identifier of the sender of the message. + */ + @Column(nullable = false) + private String sender; + + /** + * The text content of the message. + */ + @Column(nullable = false, length = 2000) + private String text; + + /** + * The timestamp when the message was sent. + */ + @Column(nullable = false) + private LocalDateTime timestamp; +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/entity/UserEntity.java b/src/main/java/io/ylab/chat/entity/UserEntity.java new file mode 100644 index 0000000..12c127f --- /dev/null +++ b/src/main/java/io/ylab/chat/entity/UserEntity.java @@ -0,0 +1,36 @@ +package io.ylab.chat.entity; + +import jakarta.persistence.*; +import lombok.*; + +/** + * Entity representing a user in the system. + */ +@Entity +@Setter +@Getter +@Builder +@Table(name = "users") +@NoArgsConstructor +@AllArgsConstructor +public class UserEntity { + + /** + * The primary key of the user entity. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * The unique username of the user. + */ + @Column(unique = true, nullable = false) + private String username; + + /** + * The hashed password of the user. + */ + @Column(nullable = false) + private String passwordHash; +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/exception/RateLimitExceededException.java b/src/main/java/io/ylab/chat/exception/RateLimitExceededException.java new file mode 100644 index 0000000..c9a8c49 --- /dev/null +++ b/src/main/java/io/ylab/chat/exception/RateLimitExceededException.java @@ -0,0 +1,16 @@ +package io.ylab.chat.exception; + +/** + * Exception thrown to indicate that a rate limit has been exceeded. + */ +public class RateLimitExceededException extends RuntimeException { + + /** + * Constructs a new {@code RateLimitExceededException} with the specified detail message. + * + * @param message the detail message explaining the reason for the rate limit being exceeded + */ + public RateLimitExceededException(String message) { + super(message); + } +} diff --git a/src/main/java/io/ylab/chat/exception/UnauthorizedException.java b/src/main/java/io/ylab/chat/exception/UnauthorizedException.java new file mode 100644 index 0000000..7bab767 --- /dev/null +++ b/src/main/java/io/ylab/chat/exception/UnauthorizedException.java @@ -0,0 +1,16 @@ +package io.ylab.chat.exception; + +/** + * Exception thrown to indicate that an operation was attempted without proper authorization. + */ +public class UnauthorizedException extends RuntimeException { + + /** + * Constructs a new {@code UnauthorizedException} with the specified detail message. + * + * @param message the detail message explaining the reason for the unauthorized access + */ + public UnauthorizedException(String message) { + super(message); + } +} diff --git a/src/main/java/io/ylab/chat/metrics/OnlineUsersGauge.java b/src/main/java/io/ylab/chat/metrics/OnlineUsersGauge.java new file mode 100644 index 0000000..8558351 --- /dev/null +++ b/src/main/java/io/ylab/chat/metrics/OnlineUsersGauge.java @@ -0,0 +1,35 @@ +package io.ylab.chat.metrics; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.ylab.chat.concurrency.OnlineUserRegistry; +import org.springframework.stereotype.Component; + +/** + * Micrometer gauge that tracks the current number of online WebSocket users. + * + * @see OnlineUserRegistry + * @see io.micrometer.core.instrument.Gauge + * @see io.micrometer.core.instrument.MeterRegistry + */ +@Component +public class OnlineUsersGauge { + + private final OnlineUserRegistry registry; + private final MeterRegistry meterRegistry; + + /** + * Constructs an OnlineUsersGauge and registers the online users gauge metric. + * + * @param registry the registry that tracks online user IDs and counts + * @param meterRegistry the Micrometer registry where the gauge will be registered + */ + public OnlineUsersGauge(OnlineUserRegistry registry, MeterRegistry meterRegistry) { + this.registry = registry; + this.meterRegistry = meterRegistry; + + Gauge.builder("websocket.online.users", registry::getOnlineCount) + .description("Current number of online users") + .register(meterRegistry); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/repository/MessageRepository.java b/src/main/java/io/ylab/chat/repository/MessageRepository.java new file mode 100644 index 0000000..10a418e --- /dev/null +++ b/src/main/java/io/ylab/chat/repository/MessageRepository.java @@ -0,0 +1,20 @@ +package io.ylab.chat.repository; + +import io.ylab.chat.entity.MessageEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Repository interface for managing {@link MessageEntity} persistence operations. + */ +@Repository +public interface MessageRepository extends JpaRepository { + + /** + * Checks whether a message with the specified message ID exists in the repository. + * + * @param messageId the unique identifier of the message to check + * @return {@code true} if a message with the given ID exists, {@code false} otherwise + */ + boolean existsByMessageId(String messageId); +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/repository/UserRepository.java b/src/main/java/io/ylab/chat/repository/UserRepository.java new file mode 100644 index 0000000..3198c11 --- /dev/null +++ b/src/main/java/io/ylab/chat/repository/UserRepository.java @@ -0,0 +1,30 @@ +package io.ylab.chat.repository; + +import io.ylab.chat.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +/** + * Repository interface for managing {@link UserEntity} persistence operations. + */ +@Repository +public interface UserRepository extends JpaRepository { + + /** + * Finds a user entity by its username. + * + * @param username the username to search for + * @return an {@link Optional} containing the found {@link UserEntity}, or empty if no user is + * found + */ + Optional findByUsername(String username); + + /** + * Checks whether a user with the specified username exists in the repository. + * + * @param username the username to check for existence + * @return {@code true} if a user with the given username exists, {@code false} otherwise + */ + boolean existsByUsername(String username); +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/service/ChatService.java b/src/main/java/io/ylab/chat/service/ChatService.java new file mode 100644 index 0000000..aec9a11 --- /dev/null +++ b/src/main/java/io/ylab/chat/service/ChatService.java @@ -0,0 +1,18 @@ +package io.ylab.chat.service; + +import io.ylab.chat.dto.ChatMessageDto; + +/** + * Service interface for handling chat message operations in a real-time chat application. + */ +public interface ChatService { + + /** + * Sends a chat message by setting the sender and timestamp, persisting the message + * asynchronously, and broadcasting it to all subscribers of the "/topic/chat" WebSocket topic. + * + * @param dto the {@link ChatMessageDto} containing the message details to be sent + */ + void sendMessage(ChatMessageDto dto); + +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/service/DeadLetterQueueService.java b/src/main/java/io/ylab/chat/service/DeadLetterQueueService.java new file mode 100644 index 0000000..101118d --- /dev/null +++ b/src/main/java/io/ylab/chat/service/DeadLetterQueueService.java @@ -0,0 +1,20 @@ +package io.ylab.chat.service; + +import io.ylab.chat.entity.MessageEntity; + +/** + * Service for managing dead-letter queue (DLQ) operations in the chat system. + * + * @see MessageEntity + */ +public interface DeadLetterQueueService { + + /** + * Pushes a message that failed processing into the dead-letter queue. + * + * @param entity the message entity that failed to be processed (must not be {@code null}) + * @param error the exception that caused the failure (must not be {@code null}) + */ + void pushFailedMessage(MessageEntity entity, Throwable error); + +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/service/MessagePersistenceService.java b/src/main/java/io/ylab/chat/service/MessagePersistenceService.java new file mode 100644 index 0000000..48cb470 --- /dev/null +++ b/src/main/java/io/ylab/chat/service/MessagePersistenceService.java @@ -0,0 +1,19 @@ +package io.ylab.chat.service; + +import io.ylab.chat.entity.MessageEntity; + +/** + * Service interface for asynchronously persisting {@link MessageEntity} instances. + */ +public interface MessagePersistenceService { + + /** + * Asynchronously saves the given {@link MessageEntity} to the database within a transactional + * context. + * + * @param entity the {@link MessageEntity} to be saved asynchronously + * @throws RuntimeException if the save operation fails + */ + void saveAsync(MessageEntity entity); + +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/service/UserService.java b/src/main/java/io/ylab/chat/service/UserService.java new file mode 100644 index 0000000..59aaf0e --- /dev/null +++ b/src/main/java/io/ylab/chat/service/UserService.java @@ -0,0 +1,32 @@ +package io.ylab.chat.service; + +import io.ylab.chat.dto.AuthResponse; +import io.ylab.chat.dto.LoginRequest; +import io.ylab.chat.dto.RegisterRequest; + +/** + * Service interface for user authentication and registration operations. + */ +public interface UserService { + + /** + * Registers a new user by validating the input, encoding the password, saving the user to the + * database, and generating a JWT token for the newly registered user. + * + * @param request the {@link RegisterRequest} containing the user's registration details + * @return an {@link AuthResponse} containing the generated JWT token and the username + * @throws IllegalArgumentException if the username already exists + */ + AuthResponse register(RegisterRequest request); + + /** + * Authenticates a user by verifying their credentials and generating a JWT token upon + * successful authentication. + * + * @param request the {@link LoginRequest} containing the user's login credentials + * @return an {@link AuthResponse} containing the generated JWT token and the username + * @throws IllegalArgumentException if the credentials are invalid + */ + AuthResponse login(LoginRequest request); + +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/service/impl/ChatServiceImpl.java b/src/main/java/io/ylab/chat/service/impl/ChatServiceImpl.java new file mode 100644 index 0000000..06f495c --- /dev/null +++ b/src/main/java/io/ylab/chat/service/impl/ChatServiceImpl.java @@ -0,0 +1,71 @@ +package io.ylab.chat.service.impl; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.ylab.chat.aop.annotation.Idempotent; +import io.ylab.chat.aop.annotation.RateLimited; +import io.ylab.chat.aop.annotation.WithUserContext; +import io.ylab.chat.context.UserContext; +import io.ylab.chat.dto.ChatMessageDto; +import io.ylab.chat.entity.MessageEntity; +import io.ylab.chat.service.ChatService; +import io.ylab.chat.service.MessagePersistenceService; +import io.ylab.chat.util.TimeProvider; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +/** + * Implementation of the {@link ChatService} interface responsible for handling chat message + * operations. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatServiceImpl implements ChatService { + + private final SimpMessagingTemplate messagingTemplate; + private final MessagePersistenceService persistenceService; + private final TimeProvider timeProvider; + private final MeterRegistry meterRegistry; + private Counter messagesSentCounter; + + @PostConstruct + public void init() { + this.messagesSentCounter = Counter.builder("websocket.messages.sent") + .description("Total number of messages sent to /topic/chat") + .register(meterRegistry); + } + + /** + * {@inheritDoc} + */ + @WithUserContext + @RateLimited + @Idempotent + @Override + public void sendMessage(ChatMessageDto dto) { + String sender = UserContext.getCurrentUsername(); + LocalDateTime now = timeProvider.now(); + dto.setSender(sender); + dto.setTimestamp(now.toInstant(ZoneOffset.UTC).toEpochMilli()); + + MessageEntity entity = MessageEntity.builder() + .messageId(dto.getMessageId()) + .sender(sender) + .text(dto.getText()) + .timestamp(now) + .build(); + + persistenceService.saveAsync(entity); + messagingTemplate.convertAndSend("/topic/chat", dto); + + messagesSentCounter.increment(); + + log.info("Message sent from {} to /topic/chat", sender); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/service/impl/DeadLetterQueueServiceImpl.java b/src/main/java/io/ylab/chat/service/impl/DeadLetterQueueServiceImpl.java new file mode 100644 index 0000000..64e1a4b --- /dev/null +++ b/src/main/java/io/ylab/chat/service/impl/DeadLetterQueueServiceImpl.java @@ -0,0 +1,48 @@ +package io.ylab.chat.service.impl; + +import io.micrometer.observation.annotation.Observed; +import io.ylab.chat.config.redis.RedisConstants; +import io.ylab.chat.entity.MessageEntity; +import io.ylab.chat.service.DeadLetterQueueService; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +/** + * Implementation of {@link DeadLetterQueueService} that stores failed messages in a Redis Stream + * for later inspection and replay. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DeadLetterQueueServiceImpl implements DeadLetterQueueService { + + private final RedisTemplate messageEntityRedisTemplate; + + /** + * {@inheritDoc} + */ + @Observed(name = "dlq.pushFailedMessage") + @Override + public void pushFailedMessage(MessageEntity entity, Throwable error) { + try { + Map fields = new HashMap<>(); + fields.put("messageId", entity.getMessageId()); + fields.put("sender", entity.getSender()); + fields.put("text", entity.getText()); + fields.put("timestamp", + entity.getTimestamp() != null ? entity.getTimestamp().toString() : null); + fields.put("error", error.getMessage()); + fields.put("failedAt", Instant.now().toString()); + messageEntityRedisTemplate.opsForStream() + .add(RedisConstants.DEAD_LETTER_STREAM_KEY, fields); + log.info("Failed message pushed to DLQ stream: {}", entity.getMessageId()); + } catch (Exception e) { + log.error("Failed to push message to DLQ: {}", entity.getMessageId(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/service/impl/MessagePersistenceServiceImpl.java b/src/main/java/io/ylab/chat/service/impl/MessagePersistenceServiceImpl.java new file mode 100644 index 0000000..2da2a85 --- /dev/null +++ b/src/main/java/io/ylab/chat/service/impl/MessagePersistenceServiceImpl.java @@ -0,0 +1,45 @@ +package io.ylab.chat.service.impl; + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.micrometer.observation.annotation.Observed; +import io.ylab.chat.entity.MessageEntity; +import io.ylab.chat.repository.MessageRepository; +import io.ylab.chat.service.DeadLetterQueueService; +import io.ylab.chat.service.MessagePersistenceService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Implementation of {@link MessagePersistenceService} responsible for asynchronously persisting + * {@link MessageEntity} instances to the database. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MessagePersistenceServiceImpl implements MessagePersistenceService { + + private final MessageRepository messageRepository; + + private final DeadLetterQueueService deadLetterQueueService; + + /** + * {@inheritDoc} + */ + @CircuitBreaker(name = "messagePersistence", fallbackMethod = "fallbackSave") + @Async("chatExecutor") + @Transactional + @Override + @Observed(name = "persistence.saveAsync", contextualName = "save-message-async") + public void saveAsync(MessageEntity entity) { + try { + messageRepository.save(entity); + log.debug("Message saved asynchronously: {}", entity.getMessageId()); + } catch (Exception e) { + log.error("Failed to save message {}: {}", entity.getMessageId(), e.getMessage()); + throw e; + } + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/service/impl/UserServiceImpl.java b/src/main/java/io/ylab/chat/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..a2f008e --- /dev/null +++ b/src/main/java/io/ylab/chat/service/impl/UserServiceImpl.java @@ -0,0 +1,86 @@ +package io.ylab.chat.service.impl; + +import io.ylab.chat.dto.AuthResponse; +import io.ylab.chat.dto.LoginRequest; +import io.ylab.chat.dto.RegisterRequest; +import io.ylab.chat.entity.UserEntity; +import io.ylab.chat.repository.UserRepository; +import io.ylab.chat.service.UserService; +import io.ylab.chat.util.JwtUtil; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Implementation of the {@link UserService} interface, providing user registration and login + * functionality. + */ +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + /** + * {@inheritDoc} + */ + @Override + @Transactional + public AuthResponse register(RegisterRequest request) { + if (userRepository.existsByUsername(request.getUsername())) { + throw new IllegalArgumentException("Username already exists"); + } + + UserEntity user = UserEntity.builder() + .username(request.getUsername()) + .passwordHash(passwordEncoder.encode(request.getPassword())) + .build(); + + userRepository.save(user); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + user.getUsername(), + null, + Collections.emptyList() + ); + + String token = jwtUtil.generateToken(authentication); + + return AuthResponse.builder() + .token(token) + .username(user.getUsername()) + .build(); + } + + /** + * {@inheritDoc} + */ + @Override + public AuthResponse login(LoginRequest request) { + UserEntity user = userRepository.findByUsername(request.getUsername()) + .orElseThrow(() -> new IllegalArgumentException("Invalid credentials")); + + if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { + throw new IllegalArgumentException("Invalid credentials"); + } + + Authentication authentication = new UsernamePasswordAuthenticationToken( + user.getUsername(), + null, + Collections.emptyList() + ); + + String token = jwtUtil.generateToken(authentication); + + return AuthResponse.builder() + .token(token) + .username(user.getUsername()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/util/JwtUtil.java b/src/main/java/io/ylab/chat/util/JwtUtil.java new file mode 100644 index 0000000..6633be8 --- /dev/null +++ b/src/main/java/io/ylab/chat/util/JwtUtil.java @@ -0,0 +1,174 @@ +package io.ylab.chat.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; +import java.util.Date; + +/** + * Utility class for generating, parsing, and validating JSON Web Tokens (JWT). + */ +@Component +public class JwtUtil { + + private static final String AUTHORITIES_CLAIM = "authorities"; + private static final String TOKEN_TYPE_CLAIM = "type"; + private static final String TOKEN_TYPE_VALUE = "JWT"; + + @Value("${jwt.secret}") + private String secretString; + + @Value("${jwt.expiration}") + private long expirationMs; + + private SecretKey key; + + /** + * Initializes the HMAC signing key from the configured secret. + * + * @throws IllegalStateException if the secret is empty, null, or shorter than 32 bytes + */ + @PostConstruct + public void init() { + if (secretString == null || secretString.trim().isEmpty()) { + throw new IllegalStateException("JWT secret must not be empty"); + } + byte[] keyBytes; + try { + keyBytes = Base64.getDecoder().decode(secretString); + } catch (IllegalArgumentException e) { + keyBytes = secretString.getBytes(StandardCharsets.UTF_8); + } + if (keyBytes.length < 32) { + throw new IllegalStateException( + "JWT secret must be at least 256 bits (32 bytes) for HS256, 512 bits for HS512"); + } + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + /** + * Generates a JWT token for the given authentication object. + * + * @param authentication the Spring Security authentication containing username and authorities + * @return a compact, signed JWT string + */ + public String generateToken(Authentication authentication) { + String username = authentication.getName(); + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expirationMs); + + List authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + + return Jwts.builder() + .subject(username) + .claim(AUTHORITIES_CLAIM, authorities) + .claim(TOKEN_TYPE_CLAIM, TOKEN_TYPE_VALUE) + .issuedAt(now) + .expiration(expiryDate) + .signWith(key, Jwts.SIG.HS512) + .compact(); + } + + /** + * Extracts the username (subject) from the JWT token. + * + * @param token the JWT string + * @return the username, or {@code null} if the token is invalid (caller should use + * {@link #validateToken(String)} first) + */ + public String extractUsername(String token) { + return extractAllClaims(token).getSubject(); + } + + /** + * Extracts the expiration date from the JWT token. + * + * @param token the JWT string + * @return the expiration date + */ + public Date extractExpiration(String token) { + return extractAllClaims(token).getExpiration(); + } + + /** + * Checks whether the token has expired. + * + * @param token the JWT string + * @return {@code true} if the token is expired, {@code false} otherwise + */ + public boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + /** + * Extracts the list of authority strings from the token's {@code authorities} claim. + * + * @param token the JWT string + * @return a list of authorities (never {@code null}); empty list if claim is missing + */ + @SuppressWarnings("unchecked") + public List extractAuthorities(String token) { + Claims claims = extractAllClaims(token); + return (List) claims.getOrDefault(AUTHORITIES_CLAIM, Collections.emptyList()); + } + + /** + * Validates the token signature and expiration. + * + * @param token the JWT string + * @return {@code true} if the token is well-formed, signed correctly, and not expired + */ + public boolean validateToken(String token) { + try { + extractAllClaims(token); + return !isTokenExpired(token); + } catch (JwtException e) { + return false; + } + } + + /** + * Validates the token against an expected username. + * + * @param token the JWT string + * @param expectedUsername the username that should match the token's subject + * @return {@code true} if the token is valid and the subject matches the expected username + */ + public boolean validateToken(String token, String expectedUsername) { + try { + String username = extractUsername(token); + return username.equals(expectedUsername) && validateToken(token); + } catch (JwtException e) { + return false; + } + } + + /** + * Parses and verifies the JWT token, returning its claims payload. + * + * @param token the JWT string + * @return the claims + * @throws JwtException if the token is malformed, expired, or has an invalid signature + */ + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/util/TimeProvider.java b/src/main/java/io/ylab/chat/util/TimeProvider.java new file mode 100644 index 0000000..c10bb7f --- /dev/null +++ b/src/main/java/io/ylab/chat/util/TimeProvider.java @@ -0,0 +1,30 @@ +package io.ylab.chat.util; + +import org.springframework.stereotype.Component; +import java.time.LocalDateTime; + +/** + * A simple utility component that provides the current time. + */ +@Component +public class TimeProvider { + + /** + * Returns the current date and time from the system clock in the default time zone. + * + * @return the current {@link LocalDateTime} + */ + public LocalDateTime now() { + return LocalDateTime.now(); + } + + /** + * Returns the current time in milliseconds since the Unix epoch (January 1, 1970, 00:00:00 + * GMT). + * + * @return the current time in milliseconds + */ + public long currentTimeMillis() { + return System.currentTimeMillis(); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/websocket/ChatWebSocketController.java b/src/main/java/io/ylab/chat/websocket/ChatWebSocketController.java new file mode 100644 index 0000000..fec8eca --- /dev/null +++ b/src/main/java/io/ylab/chat/websocket/ChatWebSocketController.java @@ -0,0 +1,90 @@ +package io.ylab.chat.websocket; + +import io.ylab.chat.dto.ChatMessageDto; +import io.ylab.chat.exception.RateLimitExceededException; +import io.ylab.chat.exception.UnauthorizedException; +import io.ylab.chat.service.ChatService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Controller; +import java.security.Principal; +import java.util.ArrayList; + +/** + * The {@code ChatWebSocketController} class handles WebSocket messages related to chat + * functionality. + */ +@Controller +@RequiredArgsConstructor +@Slf4j +public class ChatWebSocketController { + + private final ChatService chatService; + private final SimpMessagingTemplate messagingTemplate; + + /** + * Handles incoming chat messages sent to the "/chat.send" WebSocket destination. + * + * @param message the {@link ChatMessageDto} payload containing the chat message + * details. + * @param principal the {@link Principal} representing the authenticated user (can be + * null). + * @param headerAccessor the {@link SimpMessageHeaderAccessor} providing access to WebSocket + * headers. + */ + @MessageMapping("/chat.send") + public void sendMessage(@Payload ChatMessageDto message, + Principal principal, + SimpMessageHeaderAccessor headerAccessor) { + try { + if (principal != null) { + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(principal.getName(), null, + new ArrayList<>()); + SecurityContextHolder.getContext().setAuthentication(auth); + log.debug("ChatWebSocketController: SecurityContext set for user {}", + principal.getName()); + } else { + log.warn("ChatWebSocketController: Principal is null!"); + } + + chatService.sendMessage(message); + + } catch (UnauthorizedException e) { + log.warn("Unauthorized message attempt: {}", e.getMessage()); + sendErrorToUser(headerAccessor, "Unauthorized: " + e.getMessage()); + + } catch (RateLimitExceededException e) { + log.warn("Rate limit exceeded: {}", e.getMessage()); + sendErrorToUser(headerAccessor, "Rate limit: " + e.getMessage()); + + } catch (Exception e) { + log.error("Error processing message: {}", e.getMessage(), e); + sendErrorToUser(headerAccessor, "Error: Message could not be sent"); + } finally { + SecurityContextHolder.clearContext(); + } + } + + /** + * Sends an error message to the user via a private WebSocket destination. + * + * @param headerAccessor the {@link SimpMessageHeaderAccessor} providing access to WebSocket + * headers. + * @param errorMessage the error message to be sent to the user. + */ + private void sendErrorToUser(SimpMessageHeaderAccessor headerAccessor, String errorMessage) { + String sessionId = headerAccessor.getSessionId(); + messagingTemplate.convertAndSendToUser( + sessionId, + "/queue/errors", + errorMessage + ); + } +} \ No newline at end of file diff --git a/src/main/java/io/ylab/chat/websocket/WebSocketEventListener.java b/src/main/java/io/ylab/chat/websocket/WebSocketEventListener.java new file mode 100644 index 0000000..1b5a316 --- /dev/null +++ b/src/main/java/io/ylab/chat/websocket/WebSocketEventListener.java @@ -0,0 +1,103 @@ +package io.ylab.chat.websocket; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.ylab.chat.concurrency.OnlineUserRegistry; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionConnectEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import java.security.Principal; +import java.util.HashMap; +import java.util.Map; + +/** + * The {@code WebSocketEventListener} class listens for WebSocket connection and disconnection + * events and handles user registration and notification broadcasting. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WebSocketEventListener { + + private final OnlineUserRegistry onlineUserRegistry; + private final SimpMessagingTemplate messagingTemplate; + private final MeterRegistry meterRegistry; + + private Counter connectCounter; + private Counter disconnectCounter; + + @PostConstruct + public void init() { + connectCounter = Counter.builder("websocket.connections.total") + .tag("event", "connect") + .register(meterRegistry); + disconnectCounter = Counter.builder("websocket.connections.total") + .tag("event", "disconnect") + .register(meterRegistry); + } + + /** + * Handles WebSocket connection events. + * + * @param event the {@link SessionConnectEvent} triggered when a WebSocket connection is + * established. + */ + @EventListener + public void handleWebSocketConnectListener(SessionConnectEvent event) { + StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); + Principal user = headerAccessor.getUser(); + String sessionId = headerAccessor.getSessionId(); + + if (user != null) { + String username = user.getName(); + onlineUserRegistry.register(username, sessionId); + + log.info("User connected: {} (session: {})", username, sessionId); + + Map joinMessage = new HashMap<>(); + joinMessage.put("type", "USER_JOINED"); + joinMessage.put("username", username); + joinMessage.put("onlineCount", onlineUserRegistry.getOnlineCount()); + + messagingTemplate.convertAndSend("/topic/chat", joinMessage); + + connectCounter.increment(); + + } + } + + /** + * Handles WebSocket disconnection events. + * + * @param event the {@link SessionDisconnectEvent} triggered when a WebSocket connection is + * closed. + */ + @EventListener + public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { + StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); + Principal user = headerAccessor.getUser(); + + if (user != null) { + String username = user.getName(); + + onlineUserRegistry.unregister(username); + + log.info("User disconnected: {}", username); + + Map leaveMessage = new HashMap<>(); + leaveMessage.put("type", "USER_LEFT"); + leaveMessage.put("username", username); + leaveMessage.put("onlineCount", onlineUserRegistry.getOnlineCount()); + + messagingTemplate.convertAndSend("/topic/chat", leaveMessage); + + disconnectCounter.increment(); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..faf7412 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,92 @@ +# Server Configuration +server: + port: 8080 + +# H2 Database +spring: + cache: + type: redis + data: + redis: + port: 6379 + password: 123 + lettuce: + pool: + max-active: 50 + max-idle: 25 + max-wait: 3000ms + min-idle: 10 + + application: + name: chat-application + task: + execution: + pool: + core-size: 5 + max-size: 10 + queue-capacity: 25 + datasource: + url: jdbc:h2:mem:chatdb + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create-drop + show-sql: false + properties: + hibernate: + format_sql: true + + # H2 Console + h2: + console: + enabled: true + path: /h2-console + +# Logging +logging: + level: + root: INFO + com.example.chat: DEBUG + org.springframework.messaging: DEBUG + org.springframework.web.socket: DEBUG + com.giffing.bucket4j: DEBUG + org.springframework.cache: DEBUG + org.springframework.data.redis: DEBUG + +rate-limiter: + messages: + capacity: 10 + refill-tokens: 10 + refill-duration-seconds: 60 + +jwt: + secret: FITSy9dGK9BlOOrOqOi3xRaWjMPgR9KQtT0GaPiBaKQ7LcYniHsdsSA78iEy8BmOGAXpkVi7Imp9dZeHfJPptA== + expiration: 900000 + +resilience4j: + circuitbreaker: + instances: + messagePersistence: + slidingWindowSize: 10 + failureRateThreshold: 50 + waitDurationInOpenState: 30s + permittedNumberOfCallsInHalfOpenState: 3 + bulkhead: + instances: + messagePersistence: + maxConcurrentCalls: 20 + maxWaitDuration: 1s + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,beans,env + endpoint: + metrics: + enabled: true + prometheus: + enabled: true \ No newline at end of file diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css new file mode 100644 index 0000000..eb1a8f9 --- /dev/null +++ b/src/main/resources/static/css/styles.css @@ -0,0 +1,233 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + height: 100vh; + display: flex; + justify-content: center; + align-items: center; +} + +.container { + width: 90%; + max-width: 800px; + background: white; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0,0,0,0.3); + overflow: hidden; + display: flex; + flex-direction: column; + height: 90vh; +} + +.header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.header h1 { + font-size: 24px; +} + +.status { + font-size: 14px; + opacity: 0.9; +} + +.auth-section { + padding: 40px; + text-align: center; +} + +.auth-section input { + width: 100%; + max-width: 300px; + padding: 12px; + margin: 8px 0; + border: 2px solid #ddd; + border-radius: 8px; + font-size: 16px; +} + +.auth-section button { + padding: 12px 32px; + margin: 8px; + border: none; + border-radius: 8px; + background: #667eea; + color: white; + font-size: 16px; + cursor: pointer; + transition: background 0.3s; +} + +.auth-section button:hover { + background: #5568d3; +} + +.chat-section { + display: none; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 20px; + background: #f8f9fa; +} + +.message { + background: white; + padding: 12px 16px; + margin: 8px 0; + border-radius: 12px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message .sender { + font-weight: bold; + color: #667eea; + margin-bottom: 4px; +} + +.message .text { + color: #333; +} + +.message .time { + font-size: 12px; + color: #999; + margin-top: 4px; +} + +.system-message { + text-align: center; + color: #666; + font-style: italic; + padding: 8px; + margin: 8px 0; +} + +.input-section { + padding: 20px; + background: white; + border-top: 1px solid #eee; + display: flex; + gap: 12px; +} + +.input-section input { + flex: 1; + padding: 12px; + border: 2px solid #ddd; + border-radius: 8px; + font-size: 16px; +} + +.input-section button { + padding: 12px 24px; + border: none; + border-radius: 8px; + background: #667eea; + color: white; + font-size: 16px; + cursor: pointer; + transition: background 0.3s; +} + +.input-section button:hover { + background: #5568d3; +} + +.error { + color: #e74c3c; + padding: 12px; + background: #fee; + border-radius: 8px; + margin: 8px 0; +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + color: #666; +} + +.typing-indicator { + padding: 8px; + color: #999; + font-style: italic; + font-size: 14px; +} + +.online-users { + position: fixed; + right: 20px; + top: 20px; + background: white; + border-radius: 8px; + padding: 15px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + min-width: 200px; +} + +.online-users h3 { + margin-bottom: 10px; + color: #667eea; +} + +.online-user { + display: flex; + align-items: center; + margin: 5px 0; +} + +.online-dot { + width: 8px; + height: 8px; + background: #4CAF50; + border-radius: 50%; + margin-right: 8px; +} + +.rate-limit-warning { + background-color: #ff9800; + color: white; + padding: 8px 12px; + border-radius: 20px; + margin: 8px 0; + text-align: center; + font-size: 14px; + animation: fadeIn 0.3s; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..a016537 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,49 @@ + + + + + + Real-Time Chat + + + + + +
+
+

💬 Real-Time Chat

+
Offline
+
+ +
+

Login or Register

+
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+ + + + + + + + + + + diff --git a/src/main/resources/static/js/api/auth.api.js b/src/main/resources/static/js/api/auth.api.js new file mode 100644 index 0000000..6e150f5 --- /dev/null +++ b/src/main/resources/static/js/api/auth.api.js @@ -0,0 +1,83 @@ +class AuthApi { + static async register(username, password) { + try { + const response = await fetch('/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username: Helpers.sanitizeInput(username), + password + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Registration failed: ${errorText}`); + } + + return await response.json(); + } catch (error) { + console.error('Registration API error:', error); + throw error; + } + } + + static async login(username, password) { + try { + const response = await fetch('/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username: Helpers.sanitizeInput(username), + password + }) + }); + + if (!response.ok) { + throw new Error('Invalid credentials'); + } + + return await response.json(); + } catch (error) { + console.error('Login API error:', error); + throw error; + } + } + + static async validateToken(token) { + try { + const response = await fetch('/validate-token', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + return response.ok; + } catch (error) { + console.error('Token validation error:', error); + return false; + } + } + + static async logout() { + try { + const response = await fetch('/logout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + return response.ok; + } catch (error) { + console.error('Logout API error:', error); + throw error; + } + } +} \ No newline at end of file diff --git a/src/main/resources/static/js/components/auth.component.js b/src/main/resources/static/js/components/auth.component.js new file mode 100644 index 0000000..61b1eb5 --- /dev/null +++ b/src/main/resources/static/js/components/auth.component.js @@ -0,0 +1,152 @@ +class AuthComponent { + constructor() { + this.ui = ui; + this.bindEvents(); + } + + bindEvents() { + if (ui.elements.loginBtn) { + ui.elements.loginBtn.addEventListener('click', () => this.handleLogin()); + } + + if (ui.elements.registerBtn) { + ui.elements.registerBtn.addEventListener('click', () => this.handleRegister()); + } + + if (ui.elements.username && ui.elements.password) { + ui.elements.username.addEventListener('keypress', (e) => { + if (e.key === 'Enter') this.handleLogin(); + }); + + ui.elements.password.addEventListener('keypress', (e) => { + if (e.key === 'Enter') this.handleLogin(); + }); + } + } + + async handleLogin() { + const username = ui.elements.username.value; + const password = ui.elements.password.value; + + if (!username || !password) { + ui.showError('Please enter both username and password'); + return; + } + + ui.updateStatus('Connecting...'); + ui.disableInputs(); + + try { + const result = await authService.login(username, password); + + if (result.success) { + console.log('Login successful, connecting WebSocket...'); + + await this.connectWebSocket(); + } else { + ui.showError(result.error); + ui.updateStatus('Offline'); + } + } catch (error) { + ui.showError('Login failed. Please try again.'); + ui.updateStatus('Offline'); + console.error('Login error:', error); + } finally { + ui.enableInputs(); + } + } + + async handleRegister() { + const username = ui.elements.username.value; + const password = ui.elements.password.value; + + if (!username || !password) { + ui.showError('Please enter both username and password'); + return; + } + + ui.updateStatus('Registering...'); + ui.disableInputs(); + + try { + const result = await authService.register(username, password); + + if (result.success) { + console.log('Registration successful, connecting WebSocket...'); + + await this.connectWebSocket(); + } else { + ui.showError(result.error); + ui.updateStatus('Offline'); + } + } catch (error) { + ui.showError('Registration failed. Please try again.'); + ui.updateStatus('Offline'); + console.error('Registration error:', error); + } finally { + ui.enableInputs(); + } + } + + async connectWebSocket() { + try { + const token = authService.getToken(); + + if (!token) { + throw new Error('No authentication token'); + } + + ui.updateStatus('Connecting to chat...'); + + + await webSocketService.connect( + token, + (frame) => { + console.log('WebSocket connected:', frame); + ui.updateStatus('Connected'); + ui.showChatSection(); + + + chatComponent.init(); + }, + (error) => { + console.error('WebSocket connection error:', error); + ui.showError('Connection failed: ' + error.message); + ui.updateStatus('Connection Failed'); + + authService.clearAuthData(); + } + ); + + } catch (error) { + ui.showError('WebSocket connection error: ' + error.message); + ui.updateStatus('Connection Failed'); + console.error('WebSocket init error:', error); + } + } + + logout() { + authService.logout(); + webSocketService.disconnect(); + ui.showAuthSection(); + ui.clearMessages(); + ui.clearInputs(); + } + + checkAutoLogin() { + if (authService.isAuthenticated) { + authService.checkAuth().then(isValid => { + if (isValid) { + console.log('Auto-login successful'); + this.connectWebSocket(); + } else { + authService.clearAuthData(); + } + }).catch(() => { + authService.clearAuthData(); + }); + } + } +} + +const authComponent = new AuthComponent(); \ No newline at end of file diff --git a/src/main/resources/static/js/components/chat.component.js b/src/main/resources/static/js/components/chat.component.js new file mode 100644 index 0000000..b05b840 --- /dev/null +++ b/src/main/resources/static/js/components/chat.component.js @@ -0,0 +1,139 @@ +class ChatComponent { + constructor() { + this.ui = ui; + this.typingTimeout = null; + this.lastTypingTime = 0; + this.bindEvents(); + } + + bindEvents() { + if (ui.elements.sendBtn) { + ui.elements.sendBtn.addEventListener('click', () => this.sendMessage()); + } + + if (ui.elements.messageInput) { + ui.elements.messageInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.sendMessage(); + } + }); + + ui.elements.messageInput.addEventListener('input', + Helpers.debounce(() => this.handleTyping(), 500) + ); + } + } + + init() { + webSocketService.subscribe('/topic/chat', (message) => { + if (message.type === 'USER_JOINED' || message.type === 'USER_LEFT') { + ui.showSystemMessage(message); + } else { + ui.showMessage(message); + } + }); + + webSocketService.subscribe('/user/queue/messages', (message) => { + this.handlePrivateMessage(message); + }); + + webSocketService.subscribe('/user/queue/errors', (error) => { + const errorMessage = error.message || error.error || error.description || JSON.stringify(error); + + if (errorMessage.includes('Too many messages')) { + const match = errorMessage.match(/Retry after (\d+) seconds/); + const retrySeconds = match ? parseInt(match[1]) : 3; + ui.showRateLimitWarning(retrySeconds); + } else { + // Все остальные ошибки показываем как обычно + ui.showError(errorMessage, false); + } + }); + + webSocketService.subscribe('/topic/typing', (typingData) => { + if (typingData.username !== authService.getCurrentUser()) { + this.showTypingIndicator(typingData.username); + } + }); + + ui.focusMessageInput(); + } + + sendMessage() { + const text = ui.getMessageInput(); + + if (!text || !webSocketService.isActive()) { + return; + } + + try { + const messageId = webSocketService.send('/app/chat.send', { text }); + console.log('Message sent with ID:', messageId); + + ui.clearMessageInput(); + this.stopTyping(); + + } catch (error) { + console.error('Error sending message:', error); + ui.showError('Failed to send message', false); + } + } + + handlePrivateMessage(message) { + console.log('Private message received:', message); + + const privateMessage = { ...message, isPrivate: true }; + ui.showMessage(privateMessage); + } + + handleTyping() { + const text = ui.getMessageInput(); + + if (text && webSocketService.isActive()) { + const now = Date.now(); + + if (now - this.lastTypingTime > 1000) { + webSocketService.send('/app/typing', { + isTyping: true + }); + this.lastTypingTime = now; + } + + clearTimeout(this.typingTimeout); + this.typingTimeout = setTimeout(() => { + this.stopTyping(); + }, 2000); + } + } + + stopTyping() { + if (webSocketService.isActive()) { + webSocketService.send('/app/typing', { + isTyping: false + }); + } + + clearTimeout(this.typingTimeout); + ui.hideTypingIndicator(); + } + + showTypingIndicator(username) { + ui.showTypingIndicator(username); + + setTimeout(() => { + ui.hideTypingIndicator(); + }, 3000); + } + + clearChat() { + ui.clearMessages(); + } + + disconnect() { + this.stopTyping(); + webSocketService.disconnect(); + } +} + +const chatComponent = new ChatComponent(); \ No newline at end of file diff --git a/src/main/resources/static/js/components/ui.component.js b/src/main/resources/static/js/components/ui.component.js new file mode 100644 index 0000000..adc34c5 --- /dev/null +++ b/src/main/resources/static/js/components/ui.component.js @@ -0,0 +1,220 @@ +class UIComponent { + constructor() { + this.elements = { + status: document.getElementById('status'), + authSection: document.getElementById('authSection'), + chatSection: document.getElementById('chatSection'), + messages: document.getElementById('messages'), + messageInput: document.getElementById('messageInput'), + authError: document.getElementById('authError'), + username: document.getElementById('username'), + password: document.getElementById('password'), + loginBtn: document.getElementById('loginBtn'), + registerBtn: document.getElementById('registerBtn'), + sendBtn: document.getElementById('sendBtn') + }; + + this.rateLimitActive = false; + this.rateLimitTimer = null; + this.rateLimitMessage = null; + } + + showAuthSection() { + this.clearRateLimit(); // <-- add this + this.elements.authSection.style.display = 'block'; + this.elements.chatSection.style.display = 'none'; + this.updateStatus('Offline'); + } + + showChatSection() { + this.clearRateLimit(); // <-- add this + this.elements.authSection.style.display = 'none'; + this.elements.chatSection.style.display = 'flex'; + } + + updateStatus(status) { + if (this.elements.status) { + this.elements.status.textContent = status; + } + } + + showMessage(messageData) { + const messageDiv = document.createElement('div'); + messageDiv.className = 'message'; + + const time = Helpers.formatTime(messageData.timestamp); + const isCurrentUser = messageData.sender === authService.getCurrentUser(); + + messageDiv.innerHTML = ` +
+ ${isCurrentUser ? 'You' : Helpers.escapeHtml(messageData.sender)} +
+
${Helpers.escapeHtml(messageData.text)}
+
${time}
+ `; + + if (isCurrentUser) { + messageDiv.style.backgroundColor = '#f0f0ff'; + } + + this.elements.messages.appendChild(messageDiv); + this.scrollToBottom(); + } + + showSystemMessage(systemData) { + const messageDiv = document.createElement('div'); + messageDiv.className = 'system-message'; + + const action = systemData.type === 'USER_JOINED' ? 'joined' : 'left'; + messageDiv.textContent = `${systemData.username} ${action} the chat (${systemData.onlineCount} online)`; + + this.elements.messages.appendChild(messageDiv); + this.scrollToBottom(); + } + + showError(message, isAuthError = true) { + const errorDiv = isAuthError ? this.elements.authError : document.createElement('div'); + + if (!isAuthError) { + errorDiv.className = 'error'; + this.elements.messages.appendChild(errorDiv); + } + + errorDiv.innerHTML = `
${Helpers.escapeHtml(message)}
`; + + if (!isAuthError) { + setTimeout(() => { + if (errorDiv.parentNode) { + errorDiv.parentNode.removeChild(errorDiv); + } + }, 5000); + } else { + setTimeout(() => { + errorDiv.innerHTML = ''; + }, 5000); + } + } + + clearMessages() { + this.elements.messages.innerHTML = ''; + } + + clearInputs() { + if (this.elements.username) this.elements.username.value = ''; + if (this.elements.password) this.elements.password.value = ''; + if (this.elements.messageInput) this.elements.messageInput.value = ''; + } + + getMessageInput() { + return this.elements.messageInput ? this.elements.messageInput.value.trim() : ''; + } + + clearMessageInput() { + if (this.elements.messageInput) { + this.elements.messageInput.value = ''; + } + } + + focusMessageInput() { + if (this.elements.messageInput) { + this.elements.messageInput.focus(); + } + } + + scrollToBottom() { + if (this.elements.messages) { + this.elements.messages.scrollTop = this.elements.messages.scrollHeight; + } + } + + showLoading(message = 'Loading...') { + const loadingDiv = document.createElement('div'); + loadingDiv.className = 'loading'; + loadingDiv.textContent = message; + this.elements.messages.appendChild(loadingDiv); + } + + showTypingIndicator(username) { + let indicator = document.getElementById('typing-indicator'); + + if (!indicator) { + indicator = document.createElement('div'); + indicator.id = 'typing-indicator'; + indicator.className = 'typing-indicator'; + this.elements.messages.appendChild(indicator); + } + + indicator.textContent = `${username} is typing...`; + } + + hideTypingIndicator() { + const indicator = document.getElementById('typing-indicator'); + if (indicator && indicator.parentNode) { + indicator.parentNode.removeChild(indicator); + } + } + + enableInputs() { + if (this.elements.messageInput) this.elements.messageInput.disabled = false; + if (this.elements.sendBtn) this.elements.sendBtn.disabled = false; + } + + disableInputs() { + if (this.elements.messageInput) this.elements.messageInput.disabled = true; + if (this.elements.sendBtn) this.elements.sendBtn.disabled = true; + } + + + showRateLimitWarning(seconds) { + // Сбрасываем предыдущее предупреждение, если оно активно + this.clearRateLimit(); + + // Блокируем поле ввода и кнопку отправки + this.disableInputs(); + + // Создаём элемент предупреждения + const warningDiv = document.createElement('div'); + warningDiv.className = 'rate-limit-warning'; + this.elements.messages.appendChild(warningDiv); + this.rateLimitMessage = warningDiv; + + const updateMessage = () => { + if (seconds <= 0) { + warningDiv.textContent = '✅ Можно снова отправлять сообщения.'; + setTimeout(() => { + if (warningDiv.parentNode) { + warningDiv.parentNode.removeChild(warningDiv); + } + this.rateLimitMessage = null; + }, 2000); + this.enableInputs(); + if (this.rateLimitTimer) { + clearInterval(this.rateLimitTimer); + this.rateLimitTimer = null; + } + } else { + warningDiv.textContent = `⏳ Слишком много сообщений. Подождите ${seconds} сек.`; + } + }; + + updateMessage(); + + this.rateLimitTimer = setInterval(() => { + seconds--; + updateMessage(); + }, 1000); + } + + clearRateLimit() { + if (this.rateLimitTimer) { + clearInterval(this.rateLimitTimer); + this.rateLimitTimer = null; + } + if (this.rateLimitMessage && this.rateLimitMessage.parentNode) { + this.rateLimitMessage.parentNode.removeChild(this.rateLimitMessage); + this.rateLimitMessage = null; + } + this.enableInputs(); + } +} +const ui = new UIComponent(); \ No newline at end of file diff --git a/src/main/resources/static/js/main.js b/src/main/resources/static/js/main.js new file mode 100644 index 0000000..35ad9e9 --- /dev/null +++ b/src/main/resources/static/js/main.js @@ -0,0 +1,41 @@ +class ChatApp { + constructor() { + this.init(); + } + + init() { + console.log('ChatApp initializing...'); + + if (authService.isAuthenticated) { + console.log('User is authenticated, attempting auto-login...'); + authComponent.checkAutoLogin(); + } + + window.addEventListener('beforeunload', () => { + this.cleanup(); + }); + + window.addEventListener('online', () => { + console.log('Network connection restored'); + if (authService.isAuthenticated && !webSocketService.isConnected) { + authComponent.initWebSocket(); + } + }); + + window.addEventListener('offline', () => { + console.log('Network connection lost'); + ui.updateStatus('Offline'); + }); + + console.log('ChatApp initialized'); + } + + cleanup() { + console.log('Cleaning up before unload...'); + chatComponent.disconnect(); + } +} + +document.addEventListener('DOMContentLoaded', () => { + new ChatApp(); +}); \ No newline at end of file diff --git a/src/main/resources/static/js/services/auth.service.js b/src/main/resources/static/js/services/auth.service.js new file mode 100644 index 0000000..0128c4a --- /dev/null +++ b/src/main/resources/static/js/services/auth.service.js @@ -0,0 +1,102 @@ +class AuthService { + constructor() { + this.token = localStorage.getItem('chatToken'); + this.currentUser = localStorage.getItem('chatUser'); + this.isAuthenticated = !!this.token; + } + + async login(username, password) { + try { + if (!Helpers.validateUsername(username)) { + throw new Error('Username must be between 3 and 20 characters'); + } + + if (!Helpers.validatePassword(password)) { + throw new Error('Password must be at least 6 characters'); + } + + const data = await AuthApi.login(username, password); + + this.setAuthData(data.token, data.username); + + return { + success: true, + user: data.username + }; + } catch (error) { + console.error('Login service error:', error); + return { + success: false, + error: error.message + }; + } + } + + async register(username, password) { + try { + if (!Helpers.validateUsername(username)) { + throw new Error('Username must be between 3 and 20 characters'); + } + + if (!Helpers.validatePassword(password)) { + throw new Error('Password must be at least 6 characters'); + } + + const data = await AuthApi.register(username, password); + + this.setAuthData(data.token, data.username); + + return { + success: true, + user: data.username + }; + } catch (error) { + console.error('Registration service error:', error); + return { + success: false, + error: error.message + }; + } + } + + setAuthData(token, username) { + this.token = token; + this.currentUser = username; + this.isAuthenticated = true; + + localStorage.setItem('chatToken', token); + localStorage.setItem('chatUser', username); + } + + clearAuthData() { + this.token = null; + this.currentUser = null; + this.isAuthenticated = false; + + localStorage.removeItem('chatToken'); + localStorage.removeItem('chatUser'); + } + + getToken() { + return this.token; + } + + getCurrentUser() { + return this.currentUser; + } + + async checkAuth() { + if (!this.token) { + return false; + } + + return await AuthApi.validateToken(this.token); + } + + logout() { + this.clearAuthData(); + AuthApi.logout().catch(console.error); + } +} + +const authService = new AuthService(); \ No newline at end of file diff --git a/src/main/resources/static/js/services/websocket.service.js b/src/main/resources/static/js/services/websocket.service.js new file mode 100644 index 0000000..1bd3142 --- /dev/null +++ b/src/main/resources/static/js/services/websocket.service.js @@ -0,0 +1,143 @@ +class WebSocketService { + constructor() { + this.stompClient = null; + this.isConnected = false; + this.subscriptions = new Map(); + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + this.reconnectDelay = 3000; + } + + connect(token, onConnect, onError) { + return new Promise((resolve, reject) => { + try { + const socket = new SockJS('/ws'); + this.stompClient = Stomp.over(socket); + + this.stompClient.debug = null; + + const headers = { + 'Authorization': `Bearer ${token}` + }; + + this.stompClient.connect( + headers, + (frame) => { + this.isConnected = true; + this.reconnectAttempts = 0; + + console.log('WebSocket connected:', frame); + + if (onConnect) onConnect(frame); + resolve(frame); + }, + (error) => { + console.error('WebSocket connection error:', error); + this.isConnected = false; + + if (onError) onError(error); + + + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + console.log(`Reconnecting in ${this.reconnectDelay}ms... Attempt ${this.reconnectAttempts}`); + + setTimeout(() => { + this.connect(token, onConnect, onError) + .then(resolve) + .catch(reject); + }, this.reconnectDelay); + } else { + reject(new Error('Max reconnection attempts reached')); + } + } + ); + + } catch (error) { + console.error('WebSocket initialization error:', error); + reject(error); + } + }); + } + + subscribe(destination, callback) { + if (!this.stompClient || !this.isConnected) { + throw new Error('WebSocket is not connected'); + } + + const subscription = this.stompClient.subscribe(destination, (message) => { + try { + const data = JSON.parse(message.body); + callback(data); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }); + + this.subscriptions.set(destination, subscription); + + return subscription; + } + + unsubscribe(destination) { + const subscription = this.subscriptions.get(destination); + if (subscription) { + subscription.unsubscribe(); + this.subscriptions.delete(destination); + } + } + + send(destination, message, headers = {}) { + if (!this.stompClient || !this.isConnected) { + throw new Error('WebSocket is not connected'); + } + + const messageId = Helpers.generateUUID(); + const messageWithId = { + ...message, + messageId, + timestamp: Date.now() + }; + + const sendHeaders = { + ...headers, + 'Authorization': `Bearer ${authService.getToken()}` + }; + + this.stompClient.send(destination, sendHeaders, JSON.stringify(messageWithId)); + + return messageId; + } + + sendToUser(username, destination, message) { + const userDestination = `/user/${username}/${destination}`; + return this.send(userDestination, message); + } + + disconnect() { + if (this.stompClient) { + + this.subscriptions.forEach((subscription, destination) => { + subscription.unsubscribe(); + }); + this.subscriptions.clear(); + + this.stompClient.disconnect(() => { + console.log('WebSocket disconnected'); + }); + + this.stompClient = null; + this.isConnected = false; + } + } + + getConnectionStatus() { + return this.isConnected ? 'Connected' : 'Disconnected'; + } + + isActive() { + return this.isConnected && this.stompClient !== null; + } +} + +const webSocketService = new WebSocketService(); \ No newline at end of file diff --git a/src/main/resources/static/js/utils/helpers.js b/src/main/resources/static/js/utils/helpers.js new file mode 100644 index 0000000..65c0649 --- /dev/null +++ b/src/main/resources/static/js/utils/helpers.js @@ -0,0 +1,49 @@ +class Helpers { + static escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + static generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + static formatTime(timestamp) { + const date = new Date(timestamp); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + static formatDate(timestamp) { + const date = new Date(timestamp); + return date.toLocaleDateString(); + } + + static debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + static validateUsername(username) { + return username && username.length >= 3 && username.length <= 20; + } + + static validatePassword(password) { + return password && password.length >= 6; + } + + static sanitizeInput(input) { + return input.trim().replace(/[<>]/g, ''); + } +} \ No newline at end of file diff --git a/src/test/java/io/ylab/chat/aop/ChatGuardAspectTest.java b/src/test/java/io/ylab/chat/aop/ChatGuardAspectTest.java new file mode 100644 index 0000000..3a26a6d --- /dev/null +++ b/src/test/java/io/ylab/chat/aop/ChatGuardAspectTest.java @@ -0,0 +1,134 @@ +package io.ylab.chat.aop; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.github.bucket4j.BucketConfiguration; +import io.github.bucket4j.distributed.proxy.ProxyManager; +import io.ylab.chat.concurrency.MessageIdRegistry; +import io.ylab.chat.context.UserContext; +import io.ylab.chat.dto.ChatMessageDto; +import io.ylab.chat.exception.UnauthorizedException; +import java.util.function.Supplier; +import org.aspectj.lang.ProceedingJoinPoint; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +@ExtendWith({MockitoExtension.class, SoftAssertionsExtension.class}) +@DisplayName("Chat Guard Aspect Tests") +class ChatGuardAspectTest { + + private static final String TEST_USER = "testUser"; + private static final String UNAUTHORIZED_MESSAGE = "User not authenticated"; + private static final String DUPLICATE_MESSAGE_ID = "dup"; + private static final String PROCEED_RESULT = "ok"; + private static final String EXPECTED_METHOD_RESULT = "result"; + + @Mock + private ProxyManager bucketProxyManager; + + @Mock + private Supplier bucketConfigurationSupplier; + + @Mock + private MessageIdRegistry messageIdRegistry; + + @Mock + private ProceedingJoinPoint joinPoint; + + @InjectSoftAssertions + private SoftAssertions softly; + + private ChatGuardAspect aspect; + + @BeforeEach + void setUp() { + aspect = new ChatGuardAspect(bucketProxyManager, bucketConfigurationSupplier, + messageIdRegistry); + SecurityContextHolder.clearContext(); + UserContext.clear(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + UserContext.clear(); + } + + @Test + @DisplayName("Authenticated user - sets context and proceeds") + void withUserContext_authenticated_setsContextAndProceeds() throws Throwable { + Authentication auth = mock(Authentication.class); + when(auth.isAuthenticated()).thenReturn(true); + when(auth.getPrincipal()).thenReturn(TEST_USER); + when(auth.getName()).thenReturn(TEST_USER); + SecurityContextHolder.getContext().setAuthentication(auth); + + when(joinPoint.proceed()).thenReturn(EXPECTED_METHOD_RESULT); + + Object result = aspect.withUserContext(joinPoint); + + verify(joinPoint).proceed(); + softly.assertThat(result).isEqualTo(EXPECTED_METHOD_RESULT); + } + + @Test + @DisplayName("Unauthenticated user - throws UnauthorizedException") + void withUserContext_unauthenticated_throwsUnauthorized() throws Throwable { + SecurityContextHolder.getContext().setAuthentication(null); + + softly.assertThatThrownBy(() -> aspect.withUserContext(joinPoint)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(UNAUTHORIZED_MESSAGE); + } + + @Test + @DisplayName("First message without messageId - generates ID and proceeds") + void idempotent_firstMessage_withoutMessageId_generatesAndProceeds() throws Throwable { + ChatMessageDto dto = new ChatMessageDto(); + dto.setMessageId(null); + + when(joinPoint.getArgs()).thenReturn(new Object[]{dto}); + when(messageIdRegistry.markAsProcessed(anyString())).thenReturn(true); + when(joinPoint.proceed()).thenReturn(PROCEED_RESULT); + + Object result = aspect.idempotent(joinPoint); + + softly.assertThat(dto.getMessageId()) + .isNotNull() + .isNotBlank(); + + verify(messageIdRegistry).markAsProcessed(dto.getMessageId()); + softly.assertThat(result).isEqualTo(PROCEED_RESULT); + verify(joinPoint).proceed(); + } + + @Test + @DisplayName("Duplicate message ID - returns null and skips processing") + void idempotent_duplicateMessage_returnsNull() throws Throwable { + ChatMessageDto dto = new ChatMessageDto(); + dto.setMessageId(DUPLICATE_MESSAGE_ID); + when(joinPoint.getArgs()).thenReturn(new Object[]{dto}); + when(messageIdRegistry.markAsProcessed(DUPLICATE_MESSAGE_ID)).thenReturn(false); + + Object result = aspect.idempotent(joinPoint); + + softly.assertThat(result).isNull(); + verify(joinPoint, never()).proceed(); + } +} \ No newline at end of file diff --git a/src/test/java/io/ylab/chat/aop/DegradationAspectTest.java b/src/test/java/io/ylab/chat/aop/DegradationAspectTest.java new file mode 100644 index 0000000..3113544 --- /dev/null +++ b/src/test/java/io/ylab/chat/aop/DegradationAspectTest.java @@ -0,0 +1,80 @@ +package io.ylab.chat.aop; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.concurrent.atomic.AtomicBoolean; +import org.aspectj.lang.ProceedingJoinPoint; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith({MockitoExtension.class, SoftAssertionsExtension.class}) +@DisplayName("Degradation Aspect Tests") +class DegradationAspectTest { + + private static final String DATABASE_ERROR_MESSAGE = "DB error"; + private static final String DEGRADED_MODE_FIELD_NAME = "degradedMode"; + private static final int FAILURE_THRESHOLD = 5; + private static final int FAILURES_BEFORE_DEGRADED = 4; + + @InjectMocks + private DegradationAspect aspect; + + @Mock + private ProceedingJoinPoint joinPoint; + + @InjectSoftAssertions + private SoftAssertions softly; + + @Test + @DisplayName("First four failures do not degrade, fifth failure enters degraded mode") + void handlePersistenceFailure_firstFourFailures_enterDegradedAfterFifth() throws Throwable { + when(joinPoint.proceed()).thenThrow(new RuntimeException(DATABASE_ERROR_MESSAGE)); + + for (int i = 1; i <= FAILURES_BEFORE_DEGRADED; i++) { + Object result = aspect.handlePersistenceFailure(joinPoint); + softly.assertThat(result).isNull(); + softly.assertThat(aspect.isDegraded()).isFalse(); + } + + Object result = aspect.handlePersistenceFailure(joinPoint); + softly.assertThat(result).isNull(); + softly.assertThat(aspect.isDegraded()).isTrue(); + } + + @Test + @DisplayName("In degraded mode, persistence operations are skipped") + void degradedMode_skipPersistence() throws Throwable { + Field degraded = DegradationAspect.class.getDeclaredField(DEGRADED_MODE_FIELD_NAME); + degraded.setAccessible(true); + AtomicBoolean degradedMode = (AtomicBoolean) degraded.get(aspect); + degradedMode.set(true); + + Object result = aspect.handlePersistenceFailure(joinPoint); + softly.assertThat(result).isNull(); + verify(joinPoint, never()).proceed(); + } + + @Test + @DisplayName("Recovery resets degraded flag and failure count") + void recover_resetsDegradedAndFailureCount() throws Throwable { + when(joinPoint.proceed()).thenThrow(new RuntimeException()); + + for (int i = 0; i < FAILURE_THRESHOLD; i++) { + aspect.handlePersistenceFailure(joinPoint); + } + + softly.assertThat(aspect.isDegraded()).isTrue(); + aspect.recover(); + softly.assertThat(aspect.isDegraded()).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/io/ylab/chat/concurrency/MessageIdRegistryTest.java b/src/test/java/io/ylab/chat/concurrency/MessageIdRegistryTest.java new file mode 100644 index 0000000..eedd590 --- /dev/null +++ b/src/test/java/io/ylab/chat/concurrency/MessageIdRegistryTest.java @@ -0,0 +1,58 @@ +package io.ylab.chat.concurrency; + +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +@DisplayName("Message ID Registry Tests") +class MessageIdRegistryTest { + + private static final String MESSAGE_ID_1 = "id1"; + private static final String UNKNOWN_MESSAGE_ID = "unknown"; + private static final long TTL_SLEEP_MS = 300; + + private MessageIdRegistry registry; + + @InjectSoftAssertions + private SoftAssertions softly; + + @BeforeEach + void setUp() { + registry = new MessageIdRegistry(); + } + + @Test + @DisplayName("Mark as processed for new ID returns true") + void markAsProcessed_newId_returnsTrue() { + boolean first = registry.markAsProcessed(MESSAGE_ID_1); + softly.assertThat(first).isTrue(); + softly.assertThat(registry.isProcessed(MESSAGE_ID_1)).isTrue(); + } + + @Test + @DisplayName("Mark as processed for duplicate ID returns false") + void markAsProcessed_duplicateId_returnsFalse() { + registry.markAsProcessed(MESSAGE_ID_1); + boolean second = registry.markAsProcessed(MESSAGE_ID_1); + softly.assertThat(second).isFalse(); + } + + @Test + @DisplayName("Is processed returns false for unknown ID") + void isProcessed_returnsFalseForUnknown() { + softly.assertThat(registry.isProcessed(UNKNOWN_MESSAGE_ID)).isFalse(); + } + + @Test + @DisplayName("TTL removes entry after timeout") + void ttl_removesEntryAfterTimeout() throws Exception { + registry.markAsProcessed(MESSAGE_ID_1); + Thread.sleep(TTL_SLEEP_MS); + softly.assertThat(registry.isProcessed(MESSAGE_ID_1)).isTrue(); + } +} \ No newline at end of file diff --git a/src/test/java/io/ylab/chat/concurrency/OnlineUserRegistryTest.java b/src/test/java/io/ylab/chat/concurrency/OnlineUserRegistryTest.java new file mode 100644 index 0000000..86d8256 --- /dev/null +++ b/src/test/java/io/ylab/chat/concurrency/OnlineUserRegistryTest.java @@ -0,0 +1,54 @@ +package io.ylab.chat.concurrency; + +import java.util.Set; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(SoftAssertionsExtension.class) +@DisplayName("Online User Registry Tests") +class OnlineUserRegistryTest { + + private static final String USERNAME_ALICE = "alice"; + private static final String USERNAME_BOB = "bob"; + private static final String USERNAME_CHARLIE = "charlie"; + private static final String SESSION_ID_1 = "sess1"; + private static final String SESSION_ID_2 = "s1"; + private static final String SESSION_ID_3 = "s2"; + + private final OnlineUserRegistry registry = new OnlineUserRegistry(); + + @InjectSoftAssertions + private SoftAssertions softly; + + @Test + @DisplayName("Register user and check online status") + void registerAndCheckOnline() { + registry.register(USERNAME_ALICE, SESSION_ID_1); + softly.assertThat(registry.isOnline(USERNAME_ALICE)).isTrue(); + softly.assertThat(registry.getOnlineUsers()).containsExactly(USERNAME_ALICE); + softly.assertThat(registry.getOnlineCount()).isEqualTo(1); + } + + @Test + @DisplayName("Unregister removes user from registry") + void unregister_removesUser() { + registry.register(USERNAME_ALICE, SESSION_ID_1); + registry.unregister(USERNAME_ALICE); + softly.assertThat(registry.isOnline(USERNAME_ALICE)).isFalse(); + softly.assertThat(registry.getOnlineUsers()).isEmpty(); + } + + @Test + @DisplayName("Get online users returns a copy, not affected by modifications") + void getOnlineUsers_returnsCopy() { + registry.register(USERNAME_ALICE, SESSION_ID_2); + registry.register(USERNAME_BOB, SESSION_ID_3); + Set users = registry.getOnlineUsers(); + users.add(USERNAME_CHARLIE); + softly.assertThat(registry.getOnlineUsers()).containsOnly(USERNAME_ALICE, USERNAME_BOB); + } +} \ No newline at end of file diff --git a/src/test/java/io/ylab/chat/config/security/JwtAuthenticationFilterTest.java b/src/test/java/io/ylab/chat/config/security/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..f7890d7 --- /dev/null +++ b/src/test/java/io/ylab/chat/config/security/JwtAuthenticationFilterTest.java @@ -0,0 +1,87 @@ +package io.ylab.chat.config.security; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.ylab.chat.util.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +@ExtendWith({MockitoExtension.class, SoftAssertionsExtension.class}) +@DisplayName("JWT Authentication Filter Tests") +class JwtAuthenticationFilterTest { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + private static final String VALID_TOKEN = "valid.token"; + private static final String INVALID_TOKEN = "invalid"; + private static final String USERNAME_JOHN = "john"; + + @Mock + private JwtUtil jwtUtil; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private FilterChain chain; + + @InjectMocks + private JwtAuthenticationFilter filter; + + @InjectSoftAssertions + private SoftAssertions softly; + + @Test + @DisplayName("Valid token - sets authentication and continues chain") + void doFilterInternal_validToken_setsAuthentication() throws Exception { + when(request.getHeader(AUTHORIZATION_HEADER)).thenReturn(BEARER_PREFIX + VALID_TOKEN); + when(jwtUtil.extractUsername(VALID_TOKEN)).thenReturn(USERNAME_JOHN); + when(jwtUtil.validateToken(VALID_TOKEN, USERNAME_JOHN)).thenReturn(true); + + filter.doFilterInternal(request, response, chain); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + softly.assertThat(auth).isNotNull(); + softly.assertThat(auth.getName()).isEqualTo(USERNAME_JOHN); + verify(chain).doFilter(request, response); + } + + @Test + @DisplayName("Invalid token - does not set authentication and continues chain") + void doFilterInternal_invalidToken_doesNotSetAuthentication() throws Exception { + when(request.getHeader(AUTHORIZATION_HEADER)).thenReturn(BEARER_PREFIX + INVALID_TOKEN); + when(jwtUtil.extractUsername(INVALID_TOKEN)).thenThrow(new RuntimeException()); + + filter.doFilterInternal(request, response, chain); + + softly.assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(chain).doFilter(request, response); + } + + @Test + @DisplayName("No token - continues chain without authentication") + void doFilterInternal_noToken_continuesChain() throws Exception { + when(request.getHeader(AUTHORIZATION_HEADER)).thenReturn(null); + + filter.doFilterInternal(request, response, chain); + + softly.assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(chain).doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/test/java/io/ylab/chat/service/impl/ChatServiceImplTest.java b/src/test/java/io/ylab/chat/service/impl/ChatServiceImplTest.java new file mode 100644 index 0000000..764f72e --- /dev/null +++ b/src/test/java/io/ylab/chat/service/impl/ChatServiceImplTest.java @@ -0,0 +1,99 @@ +package io.ylab.chat.service.impl; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.ylab.chat.context.UserContext; +import io.ylab.chat.context.UserInfo; +import io.ylab.chat.dto.ChatMessageDto; +import io.ylab.chat.entity.MessageEntity; +import io.ylab.chat.service.MessagePersistenceService; +import io.ylab.chat.util.TimeProvider; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Objects; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +@ExtendWith({MockitoExtension.class, SoftAssertionsExtension.class}) +@DisplayName("ChatServiceImpl Tests") +class ChatServiceImplTest { + + private static final String TEST_USERNAME = "john_doe"; + private static final String MESSAGE_ID = "msg-123"; + private static final String MESSAGE_TEXT = "Hello"; + private static final String CHAT_DESTINATION = "/topic/chat"; + private static final String METRIC_MESSAGES_SENT = "websocket.messages.sent"; + private static final int TEST_YEAR = 2025; + private static final int TEST_MONTH = 4; + private static final int TEST_DAY = 5; + private static final int TEST_HOUR = 12; + private static final int TEST_MINUTE = 0; + + @Mock + private SimpMessagingTemplate messagingTemplate; + + @Mock + private MessagePersistenceService persistenceService; + + @Mock + private TimeProvider timeProvider; + + @InjectSoftAssertions + private SoftAssertions softly; + + private final MeterRegistry meterRegistry = new SimpleMeterRegistry(); + + private ChatServiceImpl chatService; + + @BeforeEach + void setUp() { + chatService = new ChatServiceImpl(messagingTemplate, persistenceService, timeProvider, + meterRegistry); + chatService.init(); + } + + @Test + @DisplayName("Send message successfully") + void sendMessage_Success() { + UserContext.setUserInfo(UserInfo.builder().username(TEST_USERNAME).build()); + LocalDateTime now = LocalDateTime.of(TEST_YEAR, TEST_MONTH, TEST_DAY, TEST_HOUR, TEST_MINUTE); + when(timeProvider.now()).thenReturn(now); + + ChatMessageDto dto = ChatMessageDto.builder() + .messageId(MESSAGE_ID) + .text(MESSAGE_TEXT) + .build(); + + chatService.sendMessage(dto); + + softly.assertThat(dto.getSender()).isEqualTo(TEST_USERNAME); + softly.assertThat(dto.getTimestamp()).isEqualTo(now.toInstant(ZoneOffset.UTC).toEpochMilli()); + + ArgumentCaptor entityCaptor = ArgumentCaptor.forClass(MessageEntity.class); + verify(persistenceService).saveAsync(entityCaptor.capture()); + MessageEntity savedEntity = entityCaptor.getValue(); + softly.assertThat(savedEntity.getMessageId()).isEqualTo(MESSAGE_ID); + softly.assertThat(savedEntity.getSender()).isEqualTo(TEST_USERNAME); + softly.assertThat(savedEntity.getText()).isEqualTo(MESSAGE_TEXT); + softly.assertThat(savedEntity.getTimestamp()).isEqualTo(now); + + verify(messagingTemplate).convertAndSend(CHAT_DESTINATION, dto); + + var counter = meterRegistry.find(METRIC_MESSAGES_SENT).counter(); + softly.assertThat(counter).isNotNull(); + softly.assertThat(Objects.requireNonNull(counter).count()).isEqualTo(1); + UserContext.clear(); + } +} \ No newline at end of file diff --git a/src/test/java/io/ylab/chat/service/impl/MessagePersistenceServiceImplTest.java b/src/test/java/io/ylab/chat/service/impl/MessagePersistenceServiceImplTest.java new file mode 100644 index 0000000..e8530e1 --- /dev/null +++ b/src/test/java/io/ylab/chat/service/impl/MessagePersistenceServiceImplTest.java @@ -0,0 +1,71 @@ +package io.ylab.chat.service.impl; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import io.ylab.chat.entity.MessageEntity; +import io.ylab.chat.repository.MessageRepository; +import io.ylab.chat.service.DeadLetterQueueService; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith({MockitoExtension.class, SoftAssertionsExtension.class}) +@DisplayName("Message Persistence Service Tests") +class MessagePersistenceServiceImplTest { + + private static final String MESSAGE_ID = "123"; + private static final String SENDER_ALICE = "alice"; + private static final String MESSAGE_TEXT = "Hi"; + private static final String DATABASE_ERROR_MESSAGE = "DB down"; + + @Mock + private MessageRepository messageRepository; + + @Mock + private DeadLetterQueueService deadLetterQueueService; + + @InjectMocks + private MessagePersistenceServiceImpl persistenceService; + + @InjectSoftAssertions + private SoftAssertions softly; + + @Test + @DisplayName("Save message asynchronously - success") + void saveAsync_Success() { + MessageEntity entity = MessageEntity.builder() + .messageId(MESSAGE_ID) + .sender(SENDER_ALICE) + .text(MESSAGE_TEXT) + .build(); + + persistenceService.saveAsync(entity); + + verify(messageRepository).save(entity); + verifyNoInteractions(deadLetterQueueService); + } + + @Test + @DisplayName("Save message asynchronously - exception propagates") + void saveAsync_Exception_ThrowsException() { + MessageEntity entity = MessageEntity.builder() + .messageId(MESSAGE_ID) + .sender(SENDER_ALICE) + .text(MESSAGE_TEXT) + .build(); + doThrow(new RuntimeException(DATABASE_ERROR_MESSAGE)).when(messageRepository).save(entity); + + softly.assertThatThrownBy(() -> persistenceService.saveAsync(entity)) + .isInstanceOf(RuntimeException.class) + .hasMessage(DATABASE_ERROR_MESSAGE); + + verifyNoInteractions(deadLetterQueueService); + } +} \ No newline at end of file diff --git a/src/test/java/io/ylab/chat/service/impl/TestCircuitBreakerConfig.java b/src/test/java/io/ylab/chat/service/impl/TestCircuitBreakerConfig.java new file mode 100644 index 0000000..83e50bc --- /dev/null +++ b/src/test/java/io/ylab/chat/service/impl/TestCircuitBreakerConfig.java @@ -0,0 +1,25 @@ +package io.ylab.chat.service.impl; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestCircuitBreakerConfig { + + @Bean + public CircuitBreakerRegistry circuitBreakerRegistry() { + CircuitBreakerConfig config = CircuitBreakerConfig.custom() + .failureRateThreshold(50) + .slidingWindowSize(2) + .build(); + return CircuitBreakerRegistry.of(config); + } + + @Bean + public CircuitBreaker circuitBreaker(CircuitBreakerRegistry registry) { + return registry.circuitBreaker("messagePersistence"); + } +} \ No newline at end of file diff --git a/src/test/java/io/ylab/chat/service/impl/UserServiceImplTest.java b/src/test/java/io/ylab/chat/service/impl/UserServiceImplTest.java new file mode 100644 index 0000000..8ca2097 --- /dev/null +++ b/src/test/java/io/ylab/chat/service/impl/UserServiceImplTest.java @@ -0,0 +1,112 @@ +package io.ylab.chat.service.impl; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.ylab.chat.dto.AuthResponse; +import io.ylab.chat.dto.LoginRequest; +import io.ylab.chat.dto.RegisterRequest; +import io.ylab.chat.entity.UserEntity; +import io.ylab.chat.repository.UserRepository; +import io.ylab.chat.util.JwtUtil; +import java.util.Optional; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith({MockitoExtension.class, SoftAssertionsExtension.class}) +@DisplayName("UserService Implementation Tests") +class UserServiceImplTest { + + private static final String USERNAME_JOHN = "john"; + private static final String PASSWORD_RAW = "pass"; + private static final String PASSWORD_ENCODED = "encoded"; + private static final String JWT_TOKEN = "jwt.token"; + private static final String JWT_TOKEN_ALT = "jwt"; + private static final String EXISTING_USER_MESSAGE = "Username already exists"; + private static final String INVALID_CREDENTIALS_MESSAGE = "Invalid credentials"; + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private JwtUtil jwtUtil; + + @InjectMocks + private UserServiceImpl userService; + + @InjectSoftAssertions + private SoftAssertions softly; + + @Test + @DisplayName("Register new user - success") + void register_newUser_success() { + RegisterRequest request = new RegisterRequest(USERNAME_JOHN, PASSWORD_RAW); + when(userRepository.existsByUsername(USERNAME_JOHN)).thenReturn(false); + when(passwordEncoder.encode(PASSWORD_RAW)).thenReturn(PASSWORD_ENCODED); + when(jwtUtil.generateToken(any(Authentication.class))).thenReturn(JWT_TOKEN); + + AuthResponse response = userService.register(request); + + softly.assertThat(response.getUsername()).isEqualTo(USERNAME_JOHN); + softly.assertThat(response.getToken()).isEqualTo(JWT_TOKEN); + verify(userRepository).save(any(UserEntity.class)); + } + + @Test + @DisplayName("Register existing user - throws exception") + void register_existingUser_throwsException() { + when(userRepository.existsByUsername(USERNAME_JOHN)).thenReturn(true); + RegisterRequest request = new RegisterRequest(USERNAME_JOHN, PASSWORD_RAW); + + softly.assertThatThrownBy(() -> userService.register(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(EXISTING_USER_MESSAGE); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("Login with valid credentials - success") + void login_validCredentials_success() { + UserEntity user = UserEntity.builder() + .username(USERNAME_JOHN) + .passwordHash(PASSWORD_ENCODED) + .build(); + when(userRepository.findByUsername(USERNAME_JOHN)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(PASSWORD_RAW, PASSWORD_ENCODED)).thenReturn(true); + when(jwtUtil.generateToken(any(Authentication.class))).thenReturn(JWT_TOKEN_ALT); + + AuthResponse response = userService.login(new LoginRequest(USERNAME_JOHN, PASSWORD_RAW)); + + softly.assertThat(response.getToken()).isEqualTo(JWT_TOKEN_ALT); + softly.assertThat(response.getUsername()).isEqualTo(USERNAME_JOHN); + } + + @Test + @DisplayName("Login with invalid password - throws exception") + void login_invalidPassword_throwsException() { + UserEntity user = UserEntity.builder() + .username(USERNAME_JOHN) + .passwordHash(PASSWORD_ENCODED) + .build(); + when(userRepository.findByUsername(USERNAME_JOHN)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrong", PASSWORD_ENCODED)).thenReturn(false); + + softly.assertThatThrownBy(() -> userService.login(new LoginRequest(USERNAME_JOHN, "wrong"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(INVALID_CREDENTIALS_MESSAGE); + } +} \ No newline at end of file diff --git a/src/test/java/io/ylab/chat/util/JwtUtilTest.java b/src/test/java/io/ylab/chat/util/JwtUtilTest.java new file mode 100644 index 0000000..d44cf1d --- /dev/null +++ b/src/test/java/io/ylab/chat/util/JwtUtilTest.java @@ -0,0 +1,73 @@ +package io.ylab.chat.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Base64; +import java.util.List; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(SoftAssertionsExtension.class) +@DisplayName("JWT Utility Tests") +class JwtUtilTest { + + private static final String TEST_USERNAME = "john"; + private static final String SECRET_KEY = "my-very-long-secret-key-that-is-at-least-512-bits-for-hs512-1234567890"; + private static final long EXPIRATION_MS = 3600000; + private static final long NEGATIVE_EXPIRATION_MS = -1000; + private static final String INVALID_TOKEN_STRING = "invalid.token.here"; + + @InjectSoftAssertions + private SoftAssertions softly; + + private JwtUtil jwtUtil; + + @BeforeEach + void setUp() { + jwtUtil = new JwtUtil(); + ReflectionTestUtils.setField(jwtUtil, "secretString", + Base64.getEncoder().encodeToString(SECRET_KEY.getBytes())); + ReflectionTestUtils.setField(jwtUtil, "expirationMs", EXPIRATION_MS); + jwtUtil.init(); + } + + @Test + @DisplayName("Generate and validate token") + void generateAndValidateToken() { + Authentication auth = new UsernamePasswordAuthenticationToken(TEST_USERNAME, null, + List.of()); + String token = jwtUtil.generateToken(auth); + assertThat(token).isNotEmpty(); + + String username = jwtUtil.extractUsername(token); + assertThat(username).isEqualTo(TEST_USERNAME); + + boolean valid = jwtUtil.validateToken(token); + softly.assertThat(valid).isTrue(); + } + + @Test + @DisplayName("Expired token validation fails") + void expiredToken_validationFails() { + ReflectionTestUtils.setField(jwtUtil, "expirationMs", NEGATIVE_EXPIRATION_MS); + jwtUtil.init(); + Authentication auth = new UsernamePasswordAuthenticationToken(TEST_USERNAME, null, + List.of()); + String token = jwtUtil.generateToken(auth); + softly.assertThat(jwtUtil.validateToken(token)).isFalse(); + } + + @Test + @DisplayName("Invalid token validation returns false") + void invalidToken_throwsException() { + softly.assertThat(jwtUtil.validateToken(INVALID_TOKEN_STRING)).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/io/ylab/chat/websocket/WebSocketEventListenerTest.java b/src/test/java/io/ylab/chat/websocket/WebSocketEventListenerTest.java new file mode 100644 index 0000000..39cc7a6 --- /dev/null +++ b/src/test/java/io/ylab/chat/websocket/WebSocketEventListenerTest.java @@ -0,0 +1,147 @@ +package io.ylab.chat.websocket; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.ylab.chat.concurrency.OnlineUserRegistry; +import java.security.Principal; +import java.util.Map; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.web.socket.messaging.SessionConnectEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +@ExtendWith({MockitoExtension.class, SoftAssertionsExtension.class}) +@DisplayName("WebSocketEventListener Tests") +class WebSocketEventListenerTest { + + private static final String USERNAME_ALICE = "alice"; + private static final String USERNAME_BOB = "bob"; + private static final String SESSION_ID = "sess1"; + private static final String CHAT_DESTINATION = "/topic/chat"; + private static final String USER_JOINED_TYPE = "USER_JOINED"; + private static final String USER_LEFT_TYPE = "USER_LEFT"; + private static final String TYPE_FIELD = "type"; + private static final String USERNAME_FIELD = "username"; + private static final String ONLINE_COUNT_FIELD = "onlineCount"; + private static final String METRIC_NAME = "websocket.connections.total"; + private static final String METRIC_TAG_EVENT = "event"; + private static final String CONNECT_EVENT = "connect"; + private static final String DISCONNECT_EVENT = "disconnect"; + + @Mock + private OnlineUserRegistry onlineUserRegistry; + + @Mock + private SimpMessagingTemplate messagingTemplate; + + private MeterRegistry meterRegistry; + + private WebSocketEventListener listener; + + @InjectSoftAssertions + private SoftAssertions softly; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + listener = new WebSocketEventListener(onlineUserRegistry, messagingTemplate, meterRegistry); + listener.init(); + } + + @Test + @DisplayName("On user connect, registers user and sends USER_JOINED message") + void handleWebSocketConnectListener_registersUserAndSendsJoinMessage() { + SessionConnectEvent event = mock(SessionConnectEvent.class); + Message message = mock(Message.class); + StompHeaderAccessor accessor = mock(StompHeaderAccessor.class); + Principal principal = mock(Principal.class); + + when(event.getMessage()).thenReturn(message); + when(principal.getName()).thenReturn(USERNAME_ALICE); + when(accessor.getUser()).thenReturn(principal); + when(accessor.getSessionId()).thenReturn(SESSION_ID); + + try (var mockedStatic = mockStatic(StompHeaderAccessor.class)) { + mockedStatic.when(() -> StompHeaderAccessor.wrap(message)).thenReturn(accessor); + + listener.handleWebSocketConnectListener(event); + + verify(onlineUserRegistry).register(USERNAME_ALICE, SESSION_ID); + + verify(messagingTemplate).convertAndSend( + eq(CHAT_DESTINATION), + ArgumentMatchers.argThat(payload -> { + if (!(payload instanceof Map map)) { + return false; + } + return USER_JOINED_TYPE.equals(map.get(TYPE_FIELD)) + && USERNAME_ALICE.equals(map.get(USERNAME_FIELD)) + && map.containsKey(ONLINE_COUNT_FIELD); + }) + ); + + softly.assertThat(meterRegistry.find(METRIC_NAME) + .tag(METRIC_TAG_EVENT, CONNECT_EVENT) + .counter()) + .isNotNull() + .satisfies(counter -> assertThat(counter.count()).isEqualTo(1.0)); + } + } + + @Test + @DisplayName("On user disconnect, removes user and sends USER_LEFT message") + void handleWebSocketDisconnectListener_unregistersAndSendsLeaveMessage() { + SessionDisconnectEvent event = mock(SessionDisconnectEvent.class); + Message message = mock(Message.class); + StompHeaderAccessor accessor = mock(StompHeaderAccessor.class); + Principal principal = mock(Principal.class); + + when(event.getMessage()).thenReturn(message); + when(principal.getName()).thenReturn(USERNAME_BOB); + when(accessor.getUser()).thenReturn(principal); + + try (var mockedStatic = mockStatic(StompHeaderAccessor.class)) { + mockedStatic.when(() -> StompHeaderAccessor.wrap(message)).thenReturn(accessor); + + listener.handleWebSocketDisconnectListener(event); + + verify(onlineUserRegistry).unregister(USERNAME_BOB); + + verify(messagingTemplate).convertAndSend( + eq(CHAT_DESTINATION), + ArgumentMatchers.argThat(payload -> { + if (!(payload instanceof Map map)) { + return false; + } + return USER_LEFT_TYPE.equals(map.get(TYPE_FIELD)) + && USERNAME_BOB.equals(map.get(USERNAME_FIELD)) + && map.containsKey(ONLINE_COUNT_FIELD); + }) + ); + + softly.assertThat(meterRegistry.find(METRIC_NAME) + .tag(METRIC_TAG_EVENT, DISCONNECT_EVENT) + .counter()) + .isNotNull() + .satisfies(counter -> assertThat(counter.count()).isEqualTo(1.0)); + } + } +} \ No newline at end of file From e296faedf563eb7c7ec0d716346026afcc78f08b Mon Sep 17 00:00:00 2001 From: AphexSign <83533118+AphexSign@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:03:32 +0300 Subject: [PATCH 2/2] Update README.md --- README.md | 184 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 37e145b..fa54efb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,182 @@ -# RealTimeChat -Chat App example +# Real-Time Chat Application + +Серверное приложение группового чата на **Spring Boot** с использованием **WebSocket (STOMP)**, многопоточности, AOP, Redis, Bucket4j и Resilience4j. + +## 🛠 Технологии + +- **Java 17** +- **Spring Boot 3.2** +- **Spring WebSocket + STOMP** +- **Spring Security + JWT** +- **Spring Data JPA** (H2 / PostgreSQL) +- **Spring AOP + AspectJ** +- **Redis** (Bucket4j + Dead Letter Stream) +- **Bucket4j** (rate limiting через Lettuce) +- **Resilience4j** (Circuit Breaker) +- **Micrometer** (метрики) +- **Lombok** +- **Testcontainers + JUnit 5** + +## 🏗 Архитектура + +Проект построен по **многослойной архитектуре** с чётким разделением ответственности и выносом сквозной логики в AOP. + +```text +┌──────────────────┐ +│ Web / WS Layer │ ← @Controller, @MessageMapping, WebSocketEventListener +├──────────────────┤ +│ Service Layer │ ← ChatService, UserService, MessagePersistenceService +│ (обёрнута AOP) │ +├──────────────────┤ +│ Cross‑cutting │ ← @WithUserContext, @RateLimited, @Idempotent, +│ (Aspects) │ @CircuitBreaker, @Async +├──────────────────┤ +│ Infrastructure │ ← ThreadPool, Redis, MeterRegistry, DLQ +├──────────────────┤ +│ Persistence │ ← JPA + Redis Streams (DLQ) +└──────────────────┘ +Структура пакетов +io.ylab.chat +├── aop/ # ChatGuardAspect, DegradationAspect +├── config/ # Security, WebSocket, Async, RateLimiter, Redis +├── controller/ # REST (Auth, Metrics) +├── websocket/ # ChatWebSocketController, WebSocketEventListener +├── service/ # ChatService, UserService, MessagePersistenceService, DLQ +├── context/ # UserContext (ThreadLocal) +├── concurrency/ # MessageIdRegistry, OnlineUserRegistry +├── entity/ # MessageEntity, UserEntity +├── repository/ # JPA репозитории +├── dto/ # ChatMessageDto, AuthRequest, AuthResponse +├── exception/ # Кастомные исключения +└── util/ # JwtUtil, TimeProvider, RedisConstants +``` +### Основная функциональность +- Аутентификация через JWT (REST + WebSocket) +- Групповой чат (одна общая комната с broadcast) +- Отображение онлайн-статуса пользователей +- Идемпотентность сообщений (защита от дублей, TTL 5 мин) +- Rate limiting сообщений через Bucket4j + Redis +- Асинхронное сохранение сообщений в БД (@Async + отдельный пул потоков) +### Отказоустойчивость: +- Circuit Breaker (Resilience4j) +- Degraded Mode (чат работает даже при падении БД) +- Dead Letter Queue на Redis Streams + +Сбор метрик через Micrometer + +🚀 Установка и запуск +Требования + +JDK 17 +Maven 3.6+ +Redis (локально или Docker) + +Запуск Redis через Docker +``` +docker run -d -p 6379:6379 --name chat-redis redis:7-alpine +``` +Запуск приложения +``` +mvn clean spring-boot:run +``` +Приложение будет доступно по адресу: http://localhost:8080 +H2 Console (для разработки): +http://localhost:8080/h2-console +JDBC URL: jdbc:h2:mem:chatdb +Конфигурация (application.yml) +```YAML +jwt: + secret: base64Encoded512bitKey... + expiration: 3600000 # 1 час + +rate-limiter: + messages: + capacity: 5 + refill-tokens: 5 + refill-duration-seconds: 60 + +spring: + data: + redis: + host: localhost + port: 6379 +``` +📡 API и WebSocket +REST Endpoints + +| Метод | URL | Описание | Аутентификация | +|-------|------------------------|-----------------------------------|---------------------| +| POST | `/register` | Регистрация пользователя | Нет | +| POST | `/login` | Авторизация → JWT токен | Нет | +| GET | `/metrics/online` | Список онлайн пользователей | Bearer JWT | +| POST | `/metrics/recover` | Выход из degraded mode | Bearer JWT | + + +WebSocket (STOMP) + +Endpoint: /ws (с поддержкой SockJS) +Подписка на сообщения: /topic/chat +Отправка сообщений: /app/chat.send +Приватные ошибки: /user/queue/errors +``` + +Пример подключения (JavaScript): +```js +JavaScriptconst token = 'your-jwt-token'; +const socket = new SockJS('/ws'); +const stompClient = Stomp.over(socket); + +stompClient.connect({ Authorization: `Bearer ${token}` }, () => { + stompClient.subscribe('/topic/chat', (msg) => { + console.log('Received:', JSON.parse(msg.body)); + }); + + stompClient.send('/app/chat.send', {}, JSON.stringify({ + text: "Привет всем!" + })); +}); +``` +Формат сообщения (ChatMessageDto): +```JSON +{ + "messageId": "550e8400-e29b-41d4-a716-446655440000", + "sender": "john", + "text": "Hello, world!", + "timestamp": 1706473200000 +} +``` +🧪 Тестирование +``` +mvn test +``` +Основные тесты: + +ChatGuardAspectTest — проверка AOP-аннотаций +MessagePersistenceFallbackTest — Circuit Breaker + DLQ +DegradationAspectTest, OnlineUserRegistryTest и др. + + +🎯 Аспекты (AOP) +| Аспект | Аннотация | Order | Ответственность | +|---------------------|----------------------------|-------|------------------------------------------------------| +| ChatGuardAspect | `@WithUserContext` | 10 | Установка UserContext + проверка аутентификации | +| ChatGuardAspect | `@Idempotent` | 10 | Защита от дублирования сообщений | +| ChatGuardAspect | `@RateLimited` | 10 | Rate limiting через Bucket4j + Redis | +| DegradationAspect | `execution(* ...saveAsync)`| 4 | Переход в degraded mode при ошибках сохранения | + + +⚙️ Многопоточность и отказоустойчивость + +Асинхронное сохранение сообщений через @Async и отдельный ThreadPoolTaskExecutor (core=8, max=16) +Dead Letter Queue — Redis Stream chat:dead-letter:stream +Degraded Mode — при 5 последовательных ошибках сохранения чат продолжает работать без записи в БД +Circuit Breaker + fallback в DLQ + +📊 Мониторинг и метрики +Доступны метрики Micrometer: + +websocket.messages.sent +websocket.connections.total +websocket.online.users (Gauge) + +Эндпоинт: GET /metrics (только для авторизованных пользователей)