Introduce Chat API with message handling, server state updates, and IP-based security restrictions.

This commit is contained in:
akastijn 2026-07-18 20:21:08 +02:00
parent 536439f324
commit 837f87d9be
10 changed files with 294 additions and 4 deletions

View File

@ -4,9 +4,11 @@ import com.alttd.altitudeweb.setup.Connection;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication(scanBasePackages = {"com.alttd.altitudeweb"}) @SpringBootApplication(scanBasePackages = {"com.alttd.altitudeweb"})
@EnableAspectJAutoProxy @EnableAspectJAutoProxy
@EnableScheduling
public class AltitudeWebApplication { public class AltitudeWebApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@ -10,6 +10,7 @@ import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jose.proc.SecurityContext;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; 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.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPrivateKey;
@ -42,17 +44,24 @@ public class SecurityConfig {
private final KeyPairService keyPairService; private final KeyPairService keyPairService;
private final SecurityAuthFailureHandler securityAuthFailureHandler; private final SecurityAuthFailureHandler securityAuthFailureHandler;
@Value("${chat.allowed-ip}")
private String allowedIp;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
WebExpressionAuthorizationManager allowedIpCheck = new WebExpressionAuthorizationManager(
"hasIpAddress('%s')".formatted(allowedIp));
return http return http
.authorizeHttpRequests( .authorizeHttpRequests(
auth -> auth auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/chat/send/**").access(allowedIpCheck)
.requestMatchers("/api/form/**").authenticated() .requestMatchers("/api/form/**").authenticated()
.requestMatchers("/api/login/getUsername").authenticated() .requestMatchers("/api/login/getUsername").authenticated()
.requestMatchers("/api/mail/**").authenticated() .requestMatchers("/api/mail/**").authenticated()
.requestMatchers("/api/site/vote").authenticated() .requestMatchers("/api/site/vote").authenticated()
.requestMatchers("/api/appeal").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/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())

View File

@ -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<Void> sendChatMessages(List<ChatMessageDto> 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<Void> updateServerStates(ServerStateDto serverStateDto) {
//TODO [Stijn] [2026-07-18]: Implement handling server state updates (push to listeners)
throw new UnsupportedOperationException("Not implemented");
}
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -12,7 +12,6 @@ import com.alttd.altitudeweb.model.PunishmentHistoryDto;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;

View File

@ -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<Instant, ChatMessage> 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<ChatMessage> getMessagesSince(Instant instant) {
return chatMessages.tailMap(instant, false).values().stream().toList();
}
}

View File

@ -18,3 +18,4 @@ spring.mail.password=${MAIL_PASSWORD}
spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.ssl.enable=true spring.mail.properties.mail.smtp.ssl.enable=true
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
chat.allowed-ip=${ALLOWED_IP:10.0.0.0/24}

View File

@ -14,6 +14,7 @@ repositories {
tasks.named("compileJava") { tasks.named("compileJava") {
dependsOn("generateJavaApi") dependsOn("generateJavaApi")
dependsOn("generateJavaChatApi")
dependsOn("generateFrontendApi") dependsOn("generateFrontendApi")
} }
@ -34,7 +35,7 @@ tasks.jar {
sourceSets { sourceSets {
main { main {
java { 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") exclude("org/openapitools/configuration/SpringDocConfiguration.java")
} }
} }
@ -59,7 +60,33 @@ tasks.register< GenerateTask>("generateJavaApi") {
library.set("spring-boot") library.set("spring-boot")
inputSpec.set("${projectDir}/src/main/resources/api.yml") inputSpec.set("${projectDir}/src/main/resources/api.yml")
configFile.set("${projectDir}/src/main/resources/config_backend.json") 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<GenerateTask>("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") typeMappings.put("OffsetDateTime", "Instant")
importMappings.put("java.time.OffsetDateTime", "java.time.Instant") importMappings.put("java.time.OffsetDateTime", "java.time.Instant")
modelNameSuffix.set("Dto") modelNameSuffix.set("Dto")

View File

@ -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