From 837f87d9be28d8d4d597da30d96381908a6a3434 Mon Sep 17 00:00:00 2001 From: akastijn Date: Sat, 18 Jul 2026 20:21:08 +0200 Subject: [PATCH] Introduce Chat API with message handling, server state updates, and IP-based security restrictions. --- .../altitudeweb/AltitudeWebApplication.java | 2 + .../altitudeweb/config/SecurityConfig.java | 9 ++ .../controllers/chat/ChatController.java | 33 ++++ .../controllers/chat/ChatMessage.java | 20 +++ .../controllers/chat/ChatMessageMapper.java | 19 +++ .../history/HistoryApiController.java | 1 - .../services/chat/ChatService.java | 38 +++++ .../src/main/resources/application.properties | 1 + open_api/build.gradle.kts | 33 +++- open_api/src/main/resources/chat-api.yml | 142 ++++++++++++++++++ 10 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatController.java create mode 100644 backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatMessage.java create mode 100644 backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatMessageMapper.java create mode 100644 backend/src/main/java/com/alttd/altitudeweb/services/chat/ChatService.java create mode 100644 open_api/src/main/resources/chat-api.yml diff --git a/backend/src/main/java/com/alttd/altitudeweb/AltitudeWebApplication.java b/backend/src/main/java/com/alttd/altitudeweb/AltitudeWebApplication.java index ad7971a..0cc98ba 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/AltitudeWebApplication.java +++ b/backend/src/main/java/com/alttd/altitudeweb/AltitudeWebApplication.java @@ -4,9 +4,11 @@ import com.alttd.altitudeweb.setup.Connection; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(scanBasePackages = {"com.alttd.altitudeweb"}) @EnableAspectJAutoProxy +@EnableScheduling public class AltitudeWebApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java index 3517e09..c2f8e54 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java +++ b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java @@ -10,6 +10,7 @@ import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; import lombok.RequiredArgsConstructor; 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.http.HttpMethod; @@ -26,6 +27,7 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager; import java.security.KeyPair; import java.security.interfaces.RSAPrivateKey; @@ -42,17 +44,24 @@ public class SecurityConfig { private final KeyPairService keyPairService; private final SecurityAuthFailureHandler securityAuthFailureHandler; + @Value("${chat.allowed-ip}") + private String allowedIp; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + WebExpressionAuthorizationManager allowedIpCheck = new WebExpressionAuthorizationManager( + "hasIpAddress('%s')".formatted(allowedIp)); return http .authorizeHttpRequests( auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/api/chat/send/**").access(allowedIpCheck) .requestMatchers("/api/form/**").authenticated() .requestMatchers("/api/login/getUsername").authenticated() .requestMatchers("/api/mail/**").authenticated() .requestMatchers("/api/site/vote").authenticated() .requestMatchers("/api/appeal").authenticated() + .requestMatchers("/api/chat/read/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatController.java new file mode 100644 index 0000000..c505d5a --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatController.java @@ -0,0 +1,33 @@ +package com.alttd.altitudeweb.controllers.chat; + +import com.alttd.altitudeweb.api.ChatApi; +import com.alttd.altitudeweb.model.ChatMessageDto; +import com.alttd.altitudeweb.model.ServerStateDto; +import com.alttd.altitudeweb.services.chat.ChatService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class ChatController implements ChatApi { + + private final ChatService chatService; + + @Override + public ResponseEntity sendChatMessages(List chatMessageDtoList) { + //TODO [Admin] [2026-07-18]: Implement handling chat messages (push to listeners) + chatMessageDtoList.stream().map(ChatMessageMapper::fromDto).forEach(chatService::addChatMessage); + throw new UnsupportedOperationException("Not implemented"); + } + + @Override + public ResponseEntity updateServerStates(ServerStateDto serverStateDto) { + //TODO [Stijn] [2026-07-18]: Implement handling server state updates (push to listeners) + throw new UnsupportedOperationException("Not implemented"); + } +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatMessage.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatMessage.java new file mode 100644 index 0000000..b168491 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatMessage.java @@ -0,0 +1,20 @@ +package com.alttd.altitudeweb.controllers.chat; + +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; +import java.util.UUID; + +@Builder +@Getter +public class ChatMessage { + + private final UUID uuid; + private final Instant timestamp; + private final String server; + //TODO [Stijn] [2026-07-18]: Handle channel types + //private final String channel; + private final String messageJson; + private final boolean blocked; +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatMessageMapper.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatMessageMapper.java new file mode 100644 index 0000000..de30c48 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/chat/ChatMessageMapper.java @@ -0,0 +1,19 @@ +package com.alttd.altitudeweb.controllers.chat; + +import com.alttd.altitudeweb.model.ChatMessageDto; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class ChatMessageMapper { + + public ChatMessage fromDto(ChatMessageDto dto) { + return ChatMessage.builder() + .uuid(dto.getUuid()) + .timestamp(dto.getTimestamp().toInstant())//TODO [Stijn] [2026-07-18]: Check this works, might be sending instant instead + .server(dto.getServer()) + .messageJson(dto.getMessage()) + .blocked(dto.getBlocked()) + .build(); + } + +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java index bd6b5e7..cb8963f 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java @@ -12,7 +12,6 @@ import com.alttd.altitudeweb.model.PunishmentHistoryDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; diff --git a/backend/src/main/java/com/alttd/altitudeweb/services/chat/ChatService.java b/backend/src/main/java/com/alttd/altitudeweb/services/chat/ChatService.java new file mode 100644 index 0000000..1217e1d --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/services/chat/ChatService.java @@ -0,0 +1,38 @@ +package com.alttd.altitudeweb.services.chat; + +import com.alttd.altitudeweb.controllers.chat.ChatMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.*; + +@Slf4j +@Service +public class ChatService { + + private static final Duration MAX_AGE = Duration.ofHours(1); + private final NavigableMap chatMessages = new TreeMap<>(); + + @Scheduled(cron = "0 * * * * *") + public void clearOldMessages() { + Instant cutoff = Instant.now().minus(MAX_AGE); + int currentSize = chatMessages.size(); + + chatMessages.headMap(cutoff, false).clear(); + + log.info("Clearing old chat messages. Removed {}/{} messages", + currentSize - chatMessages.size(), currentSize); + } + + public void addChatMessage(ChatMessage chatMessage) { + chatMessages.put(chatMessage.getTimestamp(), chatMessage); + } + + public List getMessagesSince(Instant instant) { + return chatMessages.tailMap(instant, false).values().stream().toList(); + } + +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index f6833c7..216e26c 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -18,3 +18,4 @@ spring.mail.password=${MAIL_PASSWORD} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.ssl.enable=true spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory +chat.allowed-ip=${ALLOWED_IP:10.0.0.0/24} diff --git a/open_api/build.gradle.kts b/open_api/build.gradle.kts index 24194c1..50e19a2 100644 --- a/open_api/build.gradle.kts +++ b/open_api/build.gradle.kts @@ -14,6 +14,7 @@ repositories { tasks.named("compileJava") { dependsOn("generateJavaApi") + dependsOn("generateJavaChatApi") dependsOn("generateFrontendApi") } @@ -34,7 +35,7 @@ tasks.jar { sourceSets { main { java { - srcDir("${projectDir}/build/generated-resources/model/src/main/java") + srcDir("${projectDir}/build/generated-sources/model/src/main/java") exclude("org/openapitools/configuration/SpringDocConfiguration.java") } } @@ -51,7 +52,7 @@ dependencies { implementation("org.springframework.hateoas:spring-hateoas:2.2.0") } -tasks.register< GenerateTask>("generateJavaApi") { +tasks.register("generateJavaApi") { inputs.file("${projectDir}/src/main/resources/api.yml") inputs.file("${projectDir}/src/main/resources/config_backend.json") inputs.dir("${projectDir}/src/main/resources/schemas") @@ -59,7 +60,33 @@ tasks.register< GenerateTask>("generateJavaApi") { library.set("spring-boot") inputSpec.set("${projectDir}/src/main/resources/api.yml") configFile.set("${projectDir}/src/main/resources/config_backend.json") - outputDir.set("${projectDir}/build/generated-resources/model") + outputDir.set("${projectDir}/build/generated-sources/model") + typeMappings.put("OffsetDateTime", "Instant") + importMappings.put("java.time.OffsetDateTime", "java.time.Instant") + modelNameSuffix.set("Dto") + + // Make generator use Java 8 time types and map date-time -> Instant + additionalProperties.set(mapOf("dateLibrary" to "java8")) + typeMappings.set(mapOf("date-time" to "Instant")) + importMappings.set(mapOf("Instant" to "java.time.Instant")) + generateModelTests.set(false) + generateModelDocumentation.set(false) + generateApiTests.set(false) + generateApiDocumentation.set(false) + generateAliasAsModel.set(true) + modelPackage.set("com.alttd.altitudeweb.model") + generateApiTests.set(false) +} + +tasks.register("generateJavaChatApi") { + inputs.file("${projectDir}/src/main/resources/chat-api.yml") + inputs.file("${projectDir}/src/main/resources/config_backend.json") + inputs.dir("${projectDir}/src/main/resources/schemas") + generatorName.set("spring") + library.set("spring-boot") + inputSpec.set("${projectDir}/src/main/resources/chat-api.yml") + configFile.set("${projectDir}/src/main/resources/config_backend.json") + outputDir.set("${projectDir}/build/generated-sources/model") typeMappings.put("OffsetDateTime", "Instant") importMappings.put("java.time.OffsetDateTime", "java.time.Instant") modelNameSuffix.set("Dto") diff --git a/open_api/src/main/resources/chat-api.yml b/open_api/src/main/resources/chat-api.yml new file mode 100644 index 0000000..c9ff2a2 --- /dev/null +++ b/open_api/src/main/resources/chat-api.yml @@ -0,0 +1,142 @@ +openapi: 3.0.3 + +info: + title: Minecraft Network API + version: 1.0.0 + +servers: + - url: http://localhost:8080/api + +tags: + - name: chat + description: Data for displaying Chat messages to clients + +paths: + /chat/send/chat/message: + post: + tags: + - chat + summary: Sends one or more chat messages + operationId: sendChatMessages + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ChatMessage" + responses: + "202": + description: Accepted + + /chat/send/servers/state: + post: + tags: + - chat + summary: Update server states + operationId: updateServerStates + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ServerState" + responses: + "202": + description: Accepted +components: + schemas: + User: + type: object + required: + - uuid + - name + - styledName + + properties: + uuid: + type: string + format: uuid + name: + type: string + styledName: + type: string + + ChatMessage: + type: object + required: + - uuid + - message + - channel + - server + - timestamp + - blocked + properties: + uuid: + type: string + format: uuid + channel: + type: string + enum: + - CHAT + - PARTY + - AC + - GAC + message: + type: string + server: + type: string + timestamp: + type: string + format: date-time + blocked: + type: boolean + + PrivateMessage: + type: object + required: + - sender + - receiver + - message + - senderServer + - receiverServer + properties: + sender: + type: string + format: uuid + receiver: + type: string + format: uuid + message: + type: string + senderServer: + type: string + receiverServer: + type: string + + Server: + type: object + properties: + name: + type: string + players: + type: array + items: + $ref: "#/components/schemas/User" + + ServerState: + type: object + properties: + servers: + type: array + items: + $ref: "#/components/schemas/Server" + parameters: + From: + name: from + in: path + required: true + schema: + type: string + format: date-time