Compare commits
No commits in common. "master" and "edit-punishments" have entirely different histories.
master
...
edit-punis
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -91,6 +91,3 @@ generated
|
||||||
|
|
||||||
liquibase*.properties
|
liquibase*.properties
|
||||||
node
|
node
|
||||||
|
|
||||||
.env
|
|
||||||
virtual_view/*
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ dependencies {
|
||||||
//Open API
|
//Open API
|
||||||
implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
|
implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
|
||||||
implementation("io.swagger.core.v3:swagger-models:2.2.37")
|
implementation("io.swagger.core.v3:swagger-models:2.2.37")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
|
||||||
|
|
||||||
//AOP
|
//AOP
|
||||||
implementation("org.aspectj:aspectjrt:1.9.19")
|
implementation("org.aspectj:aspectjrt:1.9.19")
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,9 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ 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;
|
||||||
|
|
@ -27,7 +26,6 @@ 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;
|
||||||
|
|
@ -44,35 +42,20 @@ 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("/actuator/mappings").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/appeal").authenticated()
|
|
||||||
.requestMatchers("/api/chat/read/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.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())
|
||||||
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
//TODO allow users access to their own folder
|
|
||||||
.requestMatchers("/api/files/download/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
|
||||||
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.requestMatchers("/api/login/userLogin/**").permitAll()
|
.requestMatchers("/api/login/userLogin/**").permitAll()
|
||||||
.requestMatchers("/api/site/chat/**").authenticated()
|
|
||||||
.requestMatchers("/api/chat/server/send/**").permitAll()
|
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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.HttpStatus;
|
|
||||||
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) {
|
|
||||||
List<ChatMessage> list = chatMessageDtoList.stream().map(ChatMessageMapper::fromDto).toList();
|
|
||||||
chatService.addChatMessage(list);
|
|
||||||
return new ResponseEntity<>(HttpStatus.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
package com.alttd.altitudeweb.controllers.chat;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.api.ChatInfoApi;
|
|
||||||
import com.alttd.altitudeweb.model.PartyNameDto;
|
|
||||||
import com.alttd.altitudeweb.model.PlayerNameDto;
|
|
||||||
import com.alttd.altitudeweb.services.chat.ChatInfoService;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@RestController
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ChatInfoController implements ChatInfoApi {
|
|
||||||
|
|
||||||
private final ChatInfoService chatInfoService;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ResponseEntity<PlayerNameDto> getName(UUID uuid) {
|
|
||||||
return chatInfoService.getPlayerName(uuid)
|
|
||||||
.map(ResponseEntity::ok)
|
|
||||||
.orElse(ResponseEntity.notFound().build());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ResponseEntity<PartyNameDto> getPartyName(String id) {
|
|
||||||
int partyId;
|
|
||||||
try {
|
|
||||||
partyId = Integer.parseInt(id);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return ResponseEntity.badRequest().build();
|
|
||||||
}
|
|
||||||
return chatInfoService.getPartyName(partyId)
|
|
||||||
.map(partyName -> {
|
|
||||||
PartyNameDto partyNameDto = new PartyNameDto();
|
|
||||||
partyNameDto.setName(partyName);
|
|
||||||
return partyNameDto;
|
|
||||||
})
|
|
||||||
.map(ResponseEntity::ok)
|
|
||||||
.orElse(ResponseEntity.notFound().build());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
package com.alttd.altitudeweb.controllers.chat;
|
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.Setter;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Builder
|
|
||||||
@Getter
|
|
||||||
public class ChatMessage {
|
|
||||||
|
|
||||||
private final UUID uuid;
|
|
||||||
@Setter
|
|
||||||
private Instant timestamp;
|
|
||||||
private final String server;
|
|
||||||
private final ChatMessageType type;
|
|
||||||
private final String channel;
|
|
||||||
private final String receiver;
|
|
||||||
private final String messageJson;
|
|
||||||
private final boolean notBlocked;
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
package com.alttd.altitudeweb.controllers.chat;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.database.chat.ChatLogDao;
|
|
||||||
import com.alttd.altitudeweb.model.ChatMessageDto;
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
|
||||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
|
||||||
import lombok.experimental.UtilityClass;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@UtilityClass
|
|
||||||
public class ChatMessageMapper {
|
|
||||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
|
|
||||||
.registerModule(new JavaTimeModule())
|
|
||||||
.enable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
|
||||||
.disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS);
|
|
||||||
|
|
||||||
public static ChatMessage fromDto(ChatMessageDto dto) {
|
|
||||||
return ChatMessage.builder()
|
|
||||||
.uuid(dto.getUuid())
|
|
||||||
.timestamp(dto.getTimestamp().toInstant())
|
|
||||||
.server(dto.getServer())
|
|
||||||
.type(fromDto(dto.getType()))
|
|
||||||
.channel(dto.getChannel())
|
|
||||||
.receiver(dto.getReceiver())
|
|
||||||
.messageJson(dto.getMessage())
|
|
||||||
.notBlocked(!dto.getBlocked())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ChatMessageType fromDto(ChatMessageDto.TypeEnum type) {
|
|
||||||
return switch (type) {
|
|
||||||
case PUBLIC -> ChatMessageType.PUBLIC;
|
|
||||||
case GLOBAL -> ChatMessageType.GLOBAL;
|
|
||||||
case PARTY -> ChatMessageType.PARTY;
|
|
||||||
case GAC -> ChatMessageType.GAC;
|
|
||||||
case MSG -> ChatMessageType.MSG;
|
|
||||||
case CUSTOM -> ChatMessageType.CUSTOM;
|
|
||||||
case UNKNOWN_DEFAULT_OPEN_API -> throw new IllegalArgumentException("Unknown chat message type");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ChatMessage fromDao(ChatLogDao dao) {
|
|
||||||
return ChatMessage.builder()
|
|
||||||
.uuid(UUID.fromString(dao.getUuid()))
|
|
||||||
.timestamp(dao.getTimeStamp().toInstant())
|
|
||||||
.server(dao.getServer())
|
|
||||||
.type(fromString(dao.getType()))
|
|
||||||
.channel(dao.getChannel())
|
|
||||||
.receiver(dao.getReceiver())
|
|
||||||
.messageJson(dao.getMiniMessage())
|
|
||||||
.notBlocked(!dao.isBlocked())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ChatMessageType fromString(String type) {
|
|
||||||
return switch (type) {
|
|
||||||
case "public" -> ChatMessageType.PUBLIC;
|
|
||||||
case "global" -> ChatMessageType.GLOBAL;
|
|
||||||
case "party" -> ChatMessageType.PARTY;
|
|
||||||
case "gac" -> ChatMessageType.GAC;
|
|
||||||
case "msg" -> ChatMessageType.MSG;
|
|
||||||
case "custom" -> ChatMessageType.CUSTOM;
|
|
||||||
default -> throw new IllegalArgumentException("Unknown chat message type");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String toJson(ChatMessage chatMessage) {
|
|
||||||
try {
|
|
||||||
return OBJECT_MAPPER.writeValueAsString(chatMessage);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new IllegalStateException("Failed to serialize ChatMessage to JSON: " + chatMessage.getUuid(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
package com.alttd.altitudeweb.controllers.chat;
|
|
||||||
|
|
||||||
public enum ChatMessageType {
|
|
||||||
PUBLIC,
|
|
||||||
GLOBAL,
|
|
||||||
PARTY,
|
|
||||||
GAC,
|
|
||||||
MSG,
|
|
||||||
CUSTOM;
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
package com.alttd.altitudeweb.controllers.chat;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.api.ServerMessageApi;
|
|
||||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
|
||||||
import com.alttd.altitudeweb.model.ServerMessageRequestDto;
|
|
||||||
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
|
|
||||||
import com.alttd.altitudeweb.services.chat.to_server.data.ChatFromWeb;
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@RestController
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ServerMessageController implements ServerMessageApi {
|
|
||||||
|
|
||||||
private final ServerMessageService serverMessageService;
|
|
||||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
||||||
private final AuthenticatedUuid authenticatedUuid;
|
|
||||||
|
|
||||||
public static String toJson(Object object) {
|
|
||||||
try {
|
|
||||||
return OBJECT_MAPPER.writeValueAsString(object);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new IllegalStateException("Failed to serialize object to JSON", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ResponseEntity<Void> sendServerMessage(String server, ServerMessageRequestDto serverMessageRequestDto) {
|
|
||||||
UUID authenticatedUserUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
|
||||||
|
|
||||||
if (!authenticatedUserUuid.equals(serverMessageRequestDto.getUuid())) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot send message as another user");
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatFromWeb chatFromWeb = ChatFromWeb.builder()
|
|
||||||
.sender(serverMessageRequestDto.getUuid())
|
|
||||||
.message(serverMessageRequestDto.getMessage())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
String json = toJson(chatFromWeb);
|
|
||||||
//TODO [Stijn] [2026-08-02]: Validate the user can send a message in this server (or do that in chat)
|
|
||||||
if (serverMessageService.sendMessage(server, "web_chat", json)) {
|
|
||||||
return new ResponseEntity<>(HttpStatus.ACCEPTED);
|
|
||||||
} else {
|
|
||||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package com.alttd.altitudeweb.controllers.data_from_auth;
|
package com.alttd.altitudeweb.controllers.data_from_auth;
|
||||||
|
|
||||||
import com.nimbusds.jwt.JWT;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
|
|
@ -10,10 +8,8 @@ import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
@Service
|
||||||
public class AuthenticatedUuid {
|
public class AuthenticatedUuid {
|
||||||
@Value("${UNSECURED:#{false}}")
|
@Value("${UNSECURED:#{false}}")
|
||||||
|
|
@ -29,9 +25,6 @@ public class AuthenticatedUuid {
|
||||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
|
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
|
||||||
log.error("Authentication principal is null {} or not a JWT {}",
|
|
||||||
authentication == null, authentication == null ?
|
|
||||||
"null" : authentication.getPrincipal() instanceof JWT);
|
|
||||||
if (unsecured) {
|
if (unsecured) {
|
||||||
return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f");
|
return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f");
|
||||||
}
|
}
|
||||||
|
|
@ -46,28 +39,4 @@ public class AuthenticatedUuid {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid UUID format");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid UUID format");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the authenticated user's UUID from the JWT token.
|
|
||||||
*
|
|
||||||
* @return The UUID of the authenticated user
|
|
||||||
*/
|
|
||||||
public Optional<UUID> tryGetAuthenticatedUserUuid() {
|
|
||||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
|
|
||||||
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
|
|
||||||
if (unsecured) {
|
|
||||||
return Optional.of(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"));
|
|
||||||
}
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
String stringUuid = jwt.getSubject();
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Optional.of(UUID.fromString(stringUuid));
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
package com.alttd.altitudeweb.controllers.event;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.security.core.token.Token;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/chat/server/send")
|
|
||||||
public class CommandsToServerController {
|
|
||||||
|
|
||||||
private final ServerMessageService serverMessageService;
|
|
||||||
@Value("${altitudeweb.token}")
|
|
||||||
private String validToken;
|
|
||||||
|
|
||||||
@GetMapping(path = "/subscribe/{server}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
|
||||||
public SseEmitter subscribe(@RequestHeader(value = "X-Altitude-Token", required = false) String token,
|
|
||||||
HttpServletResponse response, @PathVariable String server) {
|
|
||||||
if (validToken == null || validToken.equals("invalid-token")) {
|
|
||||||
log.error("Invalid token in config");
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
|
||||||
}
|
|
||||||
if (token == null) {
|
|
||||||
log.error("No token provided when trying to subscribe to {}", server);
|
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token.equals(validToken)) {
|
|
||||||
log.error("Invalid token provided when trying to subscribe to {}", server);
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
response.setHeader("X-Accel-Buffering", "no"); // disables nginx buffering if present
|
|
||||||
response.setHeader("Cache-Control", "no-cache");
|
|
||||||
|
|
||||||
return serverMessageService.subscribe(server);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
package com.alttd.altitudeweb.controllers.event;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.controllers.chat.ChatMessageMapper;
|
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
|
||||||
import com.alttd.altitudeweb.database.chat.ChatLogMapper;
|
|
||||||
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSession;
|
|
||||||
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSessionMapper;
|
|
||||||
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
|
||||||
import com.alttd.altitudeweb.services.chat.ChatService;
|
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
|
|
||||||
import com.alttd.altitudeweb.setup.Connection;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/chat/read")
|
|
||||||
public class EventController {
|
|
||||||
|
|
||||||
private final static Duration MESSAGE_HISTORY_DURATION = Duration.ofHours(1);
|
|
||||||
private final ChatService chatService;
|
|
||||||
|
|
||||||
@GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
|
||||||
public SseEmitter subscribe(@AuthenticationPrincipal Jwt jwt,
|
|
||||||
HttpServletResponse response) {
|
|
||||||
if (jwt == null) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> authorities = jwt.getClaimAsStringList("authorities");
|
|
||||||
if (authorities == null || doesNotHavePermission(authorities)) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
response.setHeader("X-Accel-Buffering", "no"); // disables nginx buffering if present
|
|
||||||
response.setHeader("Cache-Control", "no-cache");
|
|
||||||
|
|
||||||
UUID subject;
|
|
||||||
try {
|
|
||||||
subject = UUID.fromString(jwt.getSubject());
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
log.error("Invalid UUID in JWT subject: {}", jwt.getSubject());
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
String json = chatService.getMessagesSince(Instant.now().minus(MESSAGE_HISTORY_DURATION))
|
|
||||||
.stream()
|
|
||||||
.map(ChatMessageMapper::toJson)
|
|
||||||
.collect(Collectors.joining(",", "[", "]"));
|
|
||||||
|
|
||||||
Integer partyId = getPartyId(subject);
|
|
||||||
EventUser eventUser = new EventUser(subject, authorities, partyId);
|
|
||||||
return chatService.subscribe(eventUser, json);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Integer getPartyId(UUID subject) {
|
|
||||||
CompletableFuture<Integer> partyIdFuture = new CompletableFuture<>();
|
|
||||||
|
|
||||||
Connection.getConnection(Databases.CHAT)
|
|
||||||
.runQuery(sqlSession -> {
|
|
||||||
log.debug("Loading party id for uuid {}", subject);
|
|
||||||
try {
|
|
||||||
Integer partyId = sqlSession.getMapper(ChatLogMapper.class)
|
|
||||||
.getPartyId(subject.toString())
|
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
partyIdFuture.complete(partyId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to load party id", e);
|
|
||||||
partyIdFuture.complete(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return partyIdFuture.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean doesNotHavePermission(List<String> authorities) {
|
|
||||||
return !authorities.contains(PermissionClaimDto.HEAD_MOD.getValue()) && !authorities.contains(PermissionClaimDto.MOD.getValue());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,14 +10,15 @@ import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
|
||||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
|
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
|
||||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
||||||
import com.alttd.altitudeweb.mappers.AppealDataMapper;
|
import com.alttd.altitudeweb.mappers.AppealDataMapper;
|
||||||
import com.alttd.altitudeweb.model.*;
|
import com.alttd.altitudeweb.model.DiscordAppealDto;
|
||||||
import com.alttd.altitudeweb.services.forms.DiscordAppeal;
|
import com.alttd.altitudeweb.model.FormResponseDto;
|
||||||
|
import com.alttd.altitudeweb.model.MinecraftAppealDto;
|
||||||
|
import com.alttd.altitudeweb.model.UpdateMailDto;
|
||||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||||
import com.alttd.altitudeweb.services.mail.AppealMail;
|
import com.alttd.altitudeweb.services.mail.AppealMail;
|
||||||
import com.alttd.altitudeweb.setup.Connection;
|
import com.alttd.altitudeweb.setup.Connection;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.HttpStatusCode;
|
import org.springframework.http.HttpStatusCode;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
@ -35,19 +36,12 @@ public class AppealController implements AppealsApi {
|
||||||
|
|
||||||
private final AppealDataMapper mapper;
|
private final AppealDataMapper mapper;
|
||||||
private final AppealMail appealMail;
|
private final AppealMail appealMail;
|
||||||
private final DiscordAppeal discordAppeal;
|
|
||||||
private final com.alttd.altitudeweb.services.discord.AppealDiscord appealDiscord;
|
private final com.alttd.altitudeweb.services.discord.AppealDiscord appealDiscord;
|
||||||
|
|
||||||
@Override
|
|
||||||
public ResponseEntity<BannedUserResponseDto> getBannedUser(String discordId) throws Exception {
|
|
||||||
long discordIdAsLong = Long.parseLong(discordId);
|
|
||||||
return new ResponseEntity<>(discordAppeal.getBannedUser(discordIdAsLong), HttpStatus.OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
|
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
|
public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
|
||||||
return new ResponseEntity<>(discordAppeal.submitAppeal(discordAppealDto), HttpStatus.OK);
|
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
|
||||||
}
|
}
|
||||||
|
|
||||||
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
|
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ 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;
|
||||||
|
|
@ -241,8 +242,6 @@ public class HistoryApiController implements HistoryApi {
|
||||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||||
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
||||||
|
|
||||||
final UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
|
||||||
|
|
||||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||||
try {
|
try {
|
||||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||||
|
|
@ -254,6 +253,7 @@ public class HistoryApiController implements HistoryApi {
|
||||||
}
|
}
|
||||||
int changed = editMapper.setReason(historyTypeEnum, id, reason);
|
int changed = editMapper.setReason(historyTypeEnum, id, reason);
|
||||||
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||||
|
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
|
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
|
||||||
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
|
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
|
||||||
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
||||||
|
|
@ -275,8 +275,6 @@ public class HistoryApiController implements HistoryApi {
|
||||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||||
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
||||||
|
|
||||||
final UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
|
||||||
|
|
||||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||||
try {
|
try {
|
||||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||||
|
|
@ -288,6 +286,7 @@ public class HistoryApiController implements HistoryApi {
|
||||||
}
|
}
|
||||||
int changed = editMapper.setUntil(historyTypeEnum, id, until);
|
int changed = editMapper.setUntil(historyTypeEnum, id, until);
|
||||||
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||||
|
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
|
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
|
||||||
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
|
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
|
||||||
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
||||||
|
|
@ -312,8 +311,6 @@ public class HistoryApiController implements HistoryApi {
|
||||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||||
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
||||||
|
|
||||||
final UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
|
||||||
|
|
||||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||||
try {
|
try {
|
||||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||||
|
|
@ -323,6 +320,7 @@ public class HistoryApiController implements HistoryApi {
|
||||||
result.complete(false);
|
result.complete(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
|
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
|
||||||
int changed = editMapper.remove(historyTypeEnum, id);
|
int changed = editMapper.remove(historyTypeEnum, id);
|
||||||
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",
|
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",
|
||||||
|
|
|
||||||
|
|
@ -210,16 +210,15 @@ public class LoginController implements LoginApi {
|
||||||
try {
|
try {
|
||||||
log.debug("Loading user by uuid {}", uuid.toString());
|
log.debug("Loading user by uuid {}", uuid.toString());
|
||||||
PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class);
|
PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class);
|
||||||
Optional<PrivilegedUser> optionalPrivilegedUser = mapper
|
Optional<PrivilegedUser> privilegedUser = mapper
|
||||||
.getUserByUuid(uuid);
|
.getUserByUuid(uuid);
|
||||||
|
|
||||||
if (optionalPrivilegedUser.isEmpty()) {
|
if (privilegedUser.isEmpty()) {
|
||||||
PrivilegedUser privilegedUser = new PrivilegedUser(null, uuid, List.of());
|
int privilegedUserId = mapper.createPrivilegedUser(uuid);
|
||||||
mapper.createPrivilegedUser(privilegedUser);
|
|
||||||
privilegedUserCompletableFuture.complete(
|
privilegedUserCompletableFuture.complete(
|
||||||
Optional.of(privilegedUser));
|
Optional.of(new PrivilegedUser(privilegedUserId, uuid, List.of())));
|
||||||
} else {
|
} else {
|
||||||
privilegedUserCompletableFuture.complete(optionalPrivilegedUser);
|
privilegedUserCompletableFuture.complete(privilegedUser);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to load user by uuid", e);
|
log.error("Failed to load user by uuid", e);
|
||||||
|
|
|
||||||
|
|
@ -24,27 +24,34 @@ import java.nio.file.Path;
|
||||||
@RestController
|
@RestController
|
||||||
public class ParticleController implements ParticlesApi {
|
public class ParticleController implements ParticlesApi {
|
||||||
|
|
||||||
// @Value("${login.secret:#{null}}")
|
@Value("${login.secret:#{null}}")
|
||||||
// private String loginSecret;
|
private String loginSecret;
|
||||||
|
|
||||||
@Value("${particles.file_path}")
|
@Value("${particles.file_path}")
|
||||||
private String particlesFilePath;
|
private String particlesFilePath;
|
||||||
|
|
||||||
// @Value("${notification.server.url:http://localhost:8080}")
|
@Value("${notification.server.url:http://localhost:8080}")
|
||||||
// private String notificationServerUrl;
|
private String notificationServerUrl;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<Resource> downloadFile(String filename) {
|
public ResponseEntity<Resource> downloadFile(String authorization, String filename) throws Exception {
|
||||||
|
if (authorization == null || !authorization.equals(loginSecret)) {
|
||||||
|
return ResponseEntity.status(401).build();
|
||||||
|
}
|
||||||
File file = new File(particlesFilePath);
|
File file = new File(particlesFilePath);
|
||||||
if (!file.exists() || !file.isDirectory()) {
|
if (!file.exists() || !file.isDirectory()) {
|
||||||
log.error("Particles file path {} is not a directory, not downloading particles file", particlesFilePath);
|
log.error("Particles file path {} is not a directory, not downloading particles file", particlesFilePath);
|
||||||
return ResponseEntity.status(404).build();
|
return ResponseEntity.status(404).build();
|
||||||
}
|
}
|
||||||
return getFileForDownload(file, filename);
|
File targetFile = new File(file, filename);
|
||||||
|
return getFileForDownload(targetFile, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<Resource> downloadFileForUser(String uuid, String filename) {
|
public ResponseEntity<Resource> downloadFileForUser(String authorization, String uuid, String filename) throws Exception {
|
||||||
|
if (authorization == null || !authorization.equals(loginSecret)) {
|
||||||
|
return ResponseEntity.status(401).build();
|
||||||
|
}
|
||||||
File file = new File(particlesFilePath);
|
File file = new File(particlesFilePath);
|
||||||
if (!file.exists() || !file.isDirectory()) {
|
if (!file.exists() || !file.isDirectory()) {
|
||||||
log.error("Particles file path {} is not a directory, not downloading particles user file", particlesFilePath);
|
log.error("Particles file path {} is not a directory, not downloading particles user file", particlesFilePath);
|
||||||
|
|
@ -60,7 +67,6 @@ public class ParticleController implements ParticlesApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<Resource> getFileForDownload(File file, String filename) {
|
private ResponseEntity<Resource> getFileForDownload(File file, String filename) {
|
||||||
filename += ".json";
|
|
||||||
File targetFile = new File(file, filename);
|
File targetFile = new File(file, filename);
|
||||||
if (!targetFile.exists()) {
|
if (!targetFile.exists()) {
|
||||||
log.warn("Particles file {} does not exist", targetFile.getAbsolutePath());
|
log.warn("Particles file {} does not exist", targetFile.getAbsolutePath());
|
||||||
|
|
@ -89,17 +95,19 @@ public class ParticleController implements ParticlesApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) {
|
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) throws Exception {
|
||||||
File file = new File(particlesFilePath);
|
File file = new File(particlesFilePath);
|
||||||
if (!file.exists() || !file.isDirectory()) {
|
if (!file.exists() || !file.isDirectory()) {
|
||||||
log.error("Particles file path {} is not a directory, not saving particles file", particlesFilePath);
|
log.error("Particles file path {} is not a directory, not saving particles file", particlesFilePath);
|
||||||
return ResponseEntity.status(404).build();
|
return ResponseEntity.status(404).build();
|
||||||
}
|
}
|
||||||
return writeContentToFile(file, filename, content);
|
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
|
||||||
|
notifyServerOfFileUpload(filename);
|
||||||
|
return voidResponseEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) {
|
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) throws Exception {
|
||||||
File file = new File(particlesFilePath);
|
File file = new File(particlesFilePath);
|
||||||
if (!file.exists() || !file.isDirectory()) {
|
if (!file.exists() || !file.isDirectory()) {
|
||||||
log.error("Particles file path {} is not a directory, not saving particles user file", particlesFilePath);
|
log.error("Particles file path {} is not a directory, not saving particles user file", particlesFilePath);
|
||||||
|
|
@ -113,12 +121,39 @@ public class ParticleController implements ParticlesApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return writeContentToFile(file, filename, content);
|
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
|
||||||
|
notifyServerOfFileUpload(uuid, filename);
|
||||||
|
return voidResponseEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyServerOfFileUpload(String filename) {
|
||||||
|
String notificationUrl = String.format("%s/notify/%s.json", notificationServerUrl, filename);
|
||||||
|
sendNotification(notificationUrl, String.format("file upload: %s", filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyServerOfFileUpload(String uuid, String filename) {
|
||||||
|
String notificationUrl = String.format("%s/notify/%s/%s.json", notificationServerUrl, uuid, filename);
|
||||||
|
sendNotification(notificationUrl, String.format("file upload for user %s: %s", uuid, filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendNotification(String notificationUrl, String logDescription) {
|
||||||
|
try {
|
||||||
|
RestTemplate restTemplate = new RestTemplate();
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(notificationUrl, String.class);
|
||||||
|
|
||||||
|
if (response.getStatusCode().is2xxSuccessful()) {
|
||||||
|
log.info("Successfully notified server of {}", logDescription);
|
||||||
|
} else {
|
||||||
|
log.warn("Failed to notify server of {}, status: {}",
|
||||||
|
logDescription, response.getStatusCode());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error notifying server of {}", logDescription, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) {
|
private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) {
|
||||||
filename += ".json";
|
|
||||||
File targetFile = new File(dir, filename);
|
File targetFile = new File(dir, filename);
|
||||||
if (!Files.isWritable(targetFile.toPath())) {
|
if (!Files.isWritable(targetFile.toPath())) {
|
||||||
log.error("Particles file {} is not writable", targetFile.getAbsolutePath());
|
log.error("Particles file {} is not writable", targetFile.getAbsolutePath());
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,15 @@ package com.alttd.altitudeweb.controllers.site;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.api.SiteApi;
|
import com.alttd.altitudeweb.api.SiteApi;
|
||||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||||
import com.alttd.altitudeweb.model.StaffPlaytimeDto;
|
|
||||||
import com.alttd.altitudeweb.model.StaffPlaytimeListDto;
|
|
||||||
import com.alttd.altitudeweb.model.VoteDataDto;
|
import com.alttd.altitudeweb.model.VoteDataDto;
|
||||||
import com.alttd.altitudeweb.model.VoteStatsDto;
|
import com.alttd.altitudeweb.model.VoteStatsDto;
|
||||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||||
import com.alttd.altitudeweb.services.site.StaffPtService;
|
|
||||||
import com.alttd.altitudeweb.services.site.VoteService;
|
import com.alttd.altitudeweb.services.site.VoteService;
|
||||||
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.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
@ -29,22 +23,8 @@ public class SiteController implements SiteApi {
|
||||||
|
|
||||||
private final VoteService voteService;
|
private final VoteService voteService;
|
||||||
private final AuthenticatedUuid authenticatedUuid;
|
private final AuthenticatedUuid authenticatedUuid;
|
||||||
private final StaffPtService staffPtService;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@RateLimit(limit = 1, timeValue = 1, timeUnit = TimeUnit.SECONDS, key = "getStaffPlaytime")
|
|
||||||
public ResponseEntity<StaffPlaytimeListDto> getStaffPlaytime(OffsetDateTime from, OffsetDateTime to) {
|
|
||||||
Optional<List<StaffPlaytimeDto>> staffPlaytimeDto = staffPtService.getStaffPlaytime(from.toInstant(), to.toInstant());
|
|
||||||
if (staffPlaytimeDto.isEmpty()) {
|
|
||||||
return ResponseEntity.noContent().build();
|
|
||||||
}
|
|
||||||
StaffPlaytimeListDto staffPlaytimeListDto = new StaffPlaytimeListDto();
|
|
||||||
staffPlaytimeListDto.addAll(staffPlaytimeDto.get());
|
|
||||||
return ResponseEntity.ok(staffPlaytimeListDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "getVoteStats")
|
|
||||||
public ResponseEntity<VoteDataDto> getVoteStats() {
|
public ResponseEntity<VoteDataDto> getVoteStats() {
|
||||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid);
|
Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
package com.alttd.altitudeweb.mappers;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.model.BannedUserDto;
|
|
||||||
import com.alttd.webinterface.appeals.BannedUser;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class BannedUserToBannedUserDtoMapper {
|
|
||||||
|
|
||||||
public BannedUserDto map(BannedUser bannedUser) {
|
|
||||||
return new BannedUserDto(String.valueOf(bannedUser.userId()), bannedUser.reason(), bannedUser.name(), bannedUser.avatarUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
package com.alttd.altitudeweb.mappers;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal;
|
|
||||||
import com.alttd.altitudeweb.model.DiscordAppealDto;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class DiscordAppealDtoToDiscordAppealMapper {
|
|
||||||
|
|
||||||
public DiscordAppeal map(DiscordAppealDto discordAppealDto, UUID loggedInUserUuid, String discordUsername) {
|
|
||||||
return new DiscordAppeal(
|
|
||||||
UUID.randomUUID(),
|
|
||||||
loggedInUserUuid,
|
|
||||||
Long.parseLong(discordAppealDto.getDiscordId()),
|
|
||||||
discordUsername,
|
|
||||||
discordAppealDto.getAppeal(),
|
|
||||||
Instant.now(),
|
|
||||||
null,
|
|
||||||
discordAppealDto.getEmail(),
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
package com.alttd.altitudeweb.mappers;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.database.luckperms.PlayerWithGroup;
|
|
||||||
import com.alttd.altitudeweb.database.proxyplaytime.StaffPt;
|
|
||||||
import com.alttd.altitudeweb.model.StaffPlaytimeDto;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public final class StaffPtToStaffPlaytimeMapper {
|
|
||||||
private record PlaytimeInfo(long totalPlaytime, long lastPlayed) {}
|
|
||||||
|
|
||||||
public List<StaffPlaytimeDto> map(List<StaffPt> sessions, List<PlayerWithGroup> staffMembers, long from, long to, HashMap<String, String> staffGroupsMap) {
|
|
||||||
Map<UUID, PlaytimeInfo> playtimeData = getUuidPlaytimeInfoMap(sessions, from, to);
|
|
||||||
|
|
||||||
for (PlayerWithGroup staffMember : staffMembers) {
|
|
||||||
if (!playtimeData.containsKey(staffMember.uuid())) {
|
|
||||||
playtimeData.put(staffMember.uuid(), new PlaytimeInfo(0L, Long.MIN_VALUE));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<StaffPlaytimeDto> results = new ArrayList<>(playtimeData.size());
|
|
||||||
for (Map.Entry<UUID, PlaytimeInfo> entry : playtimeData.entrySet()) {
|
|
||||||
long lastPlayedMillis = entry.getValue().lastPlayed() == Long.MIN_VALUE ? 0L : entry.getValue().lastPlayed();
|
|
||||||
StaffPlaytimeDto dto = new StaffPlaytimeDto();
|
|
||||||
Optional<PlayerWithGroup> first = staffMembers.stream()
|
|
||||||
.filter(player -> player.uuid().equals(entry.getKey())).findFirst();
|
|
||||||
dto.setStaffMember(first.isPresent() ? first.get().username() : entry.getKey().toString());
|
|
||||||
dto.setStaffMember(staffMembers.stream()
|
|
||||||
.filter(player -> player.uuid().equals(entry.getKey()))
|
|
||||||
.map(PlayerWithGroup::username)
|
|
||||||
.findFirst()
|
|
||||||
.orElse(entry.getKey().toString())
|
|
||||||
);
|
|
||||||
dto.setLastPlayed(OffsetDateTime.ofInstant(Instant.ofEpochMilli(lastPlayedMillis), ZoneOffset.UTC));
|
|
||||||
dto.setPlaytime((int) TimeUnit.MILLISECONDS.toMinutes(entry.getValue().totalPlaytime()));
|
|
||||||
if (first.isPresent()) {
|
|
||||||
dto.setRole(staffGroupsMap.getOrDefault(first.get().group(), "Unknown"));
|
|
||||||
} else {
|
|
||||||
dto.setRole("Unknown");
|
|
||||||
}
|
|
||||||
results.add(dto);
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<UUID, PlaytimeInfo> getUuidPlaytimeInfoMap(List<StaffPt> sessions, long from, long to) {
|
|
||||||
Map<UUID, PlaytimeInfo> playtimeData = new HashMap<>();
|
|
||||||
for (StaffPt session : sessions) {
|
|
||||||
long overlapStart = Math.max(session.sessionStart(), from);
|
|
||||||
long overlapEnd = Math.min(session.sessionEnd(), to);
|
|
||||||
if (overlapEnd <= overlapStart) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaytimeInfo info = playtimeData.getOrDefault(session.uuid(), new PlaytimeInfo(0L, Long.MIN_VALUE));
|
|
||||||
long totalPlaytime = info.totalPlaytime() + (overlapEnd - overlapStart);
|
|
||||||
long lastPlayed = Math.max(info.lastPlayed(), overlapEnd);
|
|
||||||
playtimeData.put(session.uuid(), new PlaytimeInfo(totalPlaytime, lastPlayed));
|
|
||||||
}
|
|
||||||
return playtimeData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
package com.alttd.altitudeweb.services.chat;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
|
||||||
import com.alttd.altitudeweb.database.chat.NicknameMapper;
|
|
||||||
import com.alttd.altitudeweb.database.chat.PartyMapper;
|
|
||||||
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
|
|
||||||
import com.alttd.altitudeweb.model.PlayerNameDto;
|
|
||||||
import com.alttd.altitudeweb.setup.Connection;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ChatInfoService {
|
|
||||||
|
|
||||||
public Optional<PlayerNameDto> getPlayerName(UUID uuid) {
|
|
||||||
String username = Connection.getConnection(Databases.LUCK_PERMS)
|
|
||||||
.runQueryWithResult(sqlSession -> sqlSession.getMapper(UUIDUsernameMapper.class).getUsernameFromUUID(uuid.toString()));
|
|
||||||
|
|
||||||
if (username == null) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional<String> nickname = Connection.getConnection(Databases.CHAT)
|
|
||||||
.runQueryWithResult(sqlSession -> sqlSession.getMapper(NicknameMapper.class).getNicknameFromUUID(uuid.toString()));
|
|
||||||
|
|
||||||
PlayerNameDto playerNameDto = new PlayerNameDto();
|
|
||||||
playerNameDto.setName(username);
|
|
||||||
playerNameDto.setNickname(nickname.orElse(null));
|
|
||||||
|
|
||||||
return Optional.of(playerNameDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<String> getPartyName(int id) {
|
|
||||||
return Connection.getConnection(Databases.CHAT)
|
|
||||||
.runQueryWithResult(sqlSession -> sqlSession.getMapper(PartyMapper.class).getPartyName(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
package com.alttd.altitudeweb.services.chat;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.controllers.chat.ChatMessage;
|
|
||||||
import com.alttd.altitudeweb.controllers.chat.ChatMessageMapper;
|
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
|
||||||
import com.alttd.altitudeweb.database.chat.ChatLogMapper;
|
|
||||||
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSession;
|
|
||||||
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSessionMapper;
|
|
||||||
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
|
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.MessageForUser;
|
|
||||||
import com.alttd.altitudeweb.setup.Connection;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
|
||||||
import org.springframework.context.event.EventListener;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ChatService {
|
|
||||||
|
|
||||||
private static final Duration MAX_AGE = Duration.ofHours(1);
|
|
||||||
private final EventPublisher eventPublisher;
|
|
||||||
private final NavigableMap<Instant, ChatMessage> chatMessages = new TreeMap<>();
|
|
||||||
private final Map<String, EventUser> eventUserMap = new HashMap<>();
|
|
||||||
|
|
||||||
@Value("${chat.allowed-servers}")
|
|
||||||
private String[] allowedServers;
|
|
||||||
private long lastMillis = 0;
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
|
||||||
public void loadMessagesFromDb() {
|
|
||||||
Connection.getConnection(Databases.CHAT)
|
|
||||||
.runQuery(sqlSession -> {
|
|
||||||
log.debug("Loading uuid search result count");
|
|
||||||
try {
|
|
||||||
sqlSession.getMapper(ChatLogMapper.class)
|
|
||||||
.getChatLogs(Instant.now().minus(Duration.ofHours(1)).toEpochMilli())
|
|
||||||
.stream()
|
|
||||||
.map(ChatMessageMapper::fromDao)
|
|
||||||
.forEach(this::putChatMessage);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to load history count", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public SseEmitter subscribe(EventUser eventUser, String json) {
|
|
||||||
String key = eventUser.uuid().toString();
|
|
||||||
eventUserMap.put(key, eventUser);
|
|
||||||
return eventPublisher.subscribe(key, json, this::handleSessionEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleSessionEnd(String key, Instant sessionStart, Instant sessionEnd) {
|
|
||||||
EventUser eventUser = eventUserMap.get(key);
|
|
||||||
if (eventUser == null) {
|
|
||||||
log.error("Failed to find event user for key {}", key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ChatSession chatSession = ChatSession.builder()
|
|
||||||
.uuid(eventUser.uuid())
|
|
||||||
.session_start(sessionStart)
|
|
||||||
.session_end(sessionEnd)
|
|
||||||
.build();
|
|
||||||
saveSession(chatSession);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void saveSession(ChatSession chatSession) {
|
|
||||||
Connection.getConnection(Databases.DEFAULT)
|
|
||||||
.runQuery(sqlSession -> {
|
|
||||||
log.debug("Saving chat session");
|
|
||||||
try {
|
|
||||||
sqlSession.getMapper(ChatSessionMapper.class).storeSession(chatSession);
|
|
||||||
log.debug("Saved chat session");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to save chat session", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private synchronized void setUniqueTimestamp(ChatMessage chatMessage) {
|
|
||||||
long timestamp = chatMessage.getTimestamp().toEpochMilli();
|
|
||||||
if (timestamp == lastMillis) {
|
|
||||||
timestamp += 1;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastMillis = timestamp;
|
|
||||||
chatMessage.setTimestamp(Instant.ofEpochMilli(timestamp));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void putChatMessage(ChatMessage chatMessage) {
|
|
||||||
setUniqueTimestamp(chatMessage);
|
|
||||||
chatMessages.put(chatMessage.getTimestamp(), chatMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void addChatMessage(List<ChatMessage> chatMessageList) {
|
|
||||||
chatMessageList.forEach(this::putChatMessage);
|
|
||||||
sendMessagesToListeners(chatMessageList);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sendMessagesToListeners(List<ChatMessage> chatMessageList) {
|
|
||||||
Map<ChatMessage, String> jsonCache = new HashMap<>();
|
|
||||||
for (ChatMessage message : chatMessageList) {
|
|
||||||
jsonCache.put(message, ChatMessageMapper.toJson(message));
|
|
||||||
}
|
|
||||||
|
|
||||||
sendToUsers("chat", (eventUser) ->
|
|
||||||
chatMessageList.stream()
|
|
||||||
.filter(chatMessage -> shouldReceive(eventUser, chatMessage))
|
|
||||||
.map(jsonCache::get)
|
|
||||||
.collect(Collectors.joining(",", "[", "]"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcasts to every currently subscribed key. The message content for
|
|
||||||
* each key is resolved via messageForUser, which now works off the
|
|
||||||
* unique key itself rather than an EventUser instance.
|
|
||||||
*/
|
|
||||||
public void sendToUsers(String eventName, MessageForUser messageForUser) {
|
|
||||||
eventUserMap.forEach((key, eventUser) -> {
|
|
||||||
String json = messageForUser.get(eventUser);
|
|
||||||
eventPublisher.sendToUser(key, eventName, json);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean shouldReceive(EventUser eventUser, ChatMessage chatMessage) {
|
|
||||||
if (eventUser.hasPermission(PermissionClaimDto.HEAD_MOD)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Arrays.asList(allowedServers).contains(chatMessage.getServer())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chatMessage.isNotBlocked()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return switch (chatMessage.getType()) {
|
|
||||||
case PUBLIC, GLOBAL -> true;
|
|
||||||
case PARTY -> {
|
|
||||||
Integer partyId = eventUser.partyId();
|
|
||||||
if (partyId == null) {
|
|
||||||
yield false;
|
|
||||||
}
|
|
||||||
yield chatMessage.getChannel().equals(String.valueOf(partyId));
|
|
||||||
}
|
|
||||||
case MSG -> eventUser.uuid().toString().equals(chatMessage.getReceiver());
|
|
||||||
case GAC -> eventUser.hasPermission(PermissionClaimDto.MOD);
|
|
||||||
case CUSTOM -> false;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<ChatMessage> getMessagesSince(Instant instant) {
|
|
||||||
return chatMessages.tailMap(instant, false).values().stream().toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
package com.alttd.altitudeweb.services.chat.event_publisher;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic SSE event publisher, keyed by an arbitrary "unique" string
|
|
||||||
* The same key may be used by several concurrent connections
|
|
||||||
* (e.g., a user/server with multiple open tabs or connections).
|
|
||||||
* Each subscribe() call adds its own emitter under that key
|
|
||||||
* without evicting the others.
|
|
||||||
*/
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Service
|
|
||||||
@Slf4j
|
|
||||||
public class EventPublisher {
|
|
||||||
|
|
||||||
private final Map<String, List<SseEmitter>> emitterMap = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invoked once a given emitter for uniqueKey is cleaned up (completed,
|
|
||||||
* timed out, or errored). Callers use this to do whatever bookkeeping
|
|
||||||
* they need (e.g., persisting a Session) using sessionStart/sessionEnd.
|
|
||||||
*/
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface SessionCleanupCallback {
|
|
||||||
void onCleanup(String uniqueKey, Instant sessionStart, Instant sessionEnd);
|
|
||||||
}
|
|
||||||
public SseEmitter subscribe(String uniqueKey, SessionCleanupCallback cleanupCallback) {
|
|
||||||
return subscribe(uniqueKey, null, cleanupCallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
public SseEmitter subscribe(String uniqueKey, String initialData, SessionCleanupCallback cleanupCallback) {
|
|
||||||
SseEmitter emitter = new SseEmitter(0L); // no built-in timeout; heartbeat governs liveness
|
|
||||||
|
|
||||||
emitterMap.computeIfAbsent(uniqueKey, k -> new CopyOnWriteArrayList<>()).add(emitter);
|
|
||||||
Instant sessionStart = Instant.now();
|
|
||||||
log.info("Key {} subscribed, active emitters: {}", uniqueKey, countActive());
|
|
||||||
|
|
||||||
Runnable cleanup = () -> handleCleanup(uniqueKey, emitter, sessionStart, cleanupCallback);
|
|
||||||
|
|
||||||
// covers: clean disconnect (tab closed normally), server-side timeout, write failure
|
|
||||||
emitter.onCompletion(cleanup);
|
|
||||||
emitter.onTimeout(cleanup);
|
|
||||||
emitter.onError(e -> cleanup.run());
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (initialData != null) {
|
|
||||||
emitter.send(SseEmitter.event().name("connected").data(initialData));
|
|
||||||
} else {
|
|
||||||
emitter.send(SseEmitter.event().name("connected"));
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
cleanup.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
return emitter;
|
|
||||||
}
|
|
||||||
|
|
||||||
// runs every 20s: keeps idle timeouts from firing,
|
|
||||||
// AND prunes emitters whose sockets are actually dead
|
|
||||||
@Scheduled(fixedRate = 20_000)
|
|
||||||
public void heartbeat() {
|
|
||||||
emitterMap.values().forEach(list -> {
|
|
||||||
List<SseEmitter> dead = new ArrayList<>();
|
|
||||||
for (SseEmitter emitter : list) {
|
|
||||||
try {
|
|
||||||
emitter.send(SseEmitter.event().comment("heartbeat"));
|
|
||||||
} catch (IOException e) {
|
|
||||||
dead.add(emitter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
list.removeAll(dead);
|
|
||||||
});
|
|
||||||
emitterMap.entrySet().removeIf(e -> e.getValue().isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void sendToUser(String uniqueKey, String eventName, String json) {
|
|
||||||
List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
|
|
||||||
if (userEmitters == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<SseEmitter> dead = new ArrayList<>();
|
|
||||||
for (SseEmitter emitter : userEmitters) {
|
|
||||||
try {
|
|
||||||
emitter.send(SseEmitter.event().name(eventName).data(json));
|
|
||||||
} catch (IOException e) {
|
|
||||||
dead.add(emitter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
userEmitters.removeAll(dead);
|
|
||||||
}
|
|
||||||
|
|
||||||
private long countActive() {
|
|
||||||
return emitterMap.values().stream().mapToLong(List::size).sum();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleCleanup(String uniqueKey, SseEmitter emitter, Instant sessionStart,
|
|
||||||
SessionCleanupCallback cleanupCallback) {
|
|
||||||
List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
|
|
||||||
if (userEmitters != null) {
|
|
||||||
userEmitters.remove(emitter);
|
|
||||||
if (userEmitters.isEmpty()) {
|
|
||||||
emitterMap.remove(uniqueKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Instant sessionEnd = Instant.now();
|
|
||||||
Duration between = Duration.between(sessionStart, sessionEnd);
|
|
||||||
log.info("Cleaned up emitter for {}, active emitters: {}, session duration: {}",
|
|
||||||
uniqueKey, countActive(), between
|
|
||||||
);
|
|
||||||
|
|
||||||
if (cleanupCallback != null) {
|
|
||||||
cleanupCallback.onCleanup(uniqueKey, sessionStart, sessionEnd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
package com.alttd.altitudeweb.services.chat.event_publisher;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record EventUser(UUID uuid, List<String> authorities, Integer partyId) {
|
|
||||||
|
|
||||||
public boolean hasPermission(PermissionClaimDto permission) {
|
|
||||||
return authorities.contains(permission.getValue());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean equals(Object o) {
|
|
||||||
if (o == null || getClass() != o.getClass()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
EventUser eventUser = (EventUser) o;
|
|
||||||
return Objects.equals(uuid, eventUser.uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int hashCode() {
|
|
||||||
return Objects.hash(uuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
package com.alttd.altitudeweb.services.chat.event_publisher;
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface MessageForUser {
|
|
||||||
|
|
||||||
String get(EventUser eventUser);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
package com.alttd.altitudeweb.services.chat.to_server;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
|
|
||||||
import com.alttd.altitudeweb.services.chat.to_server.data.ChatFromWeb;
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ServerMessageService {
|
|
||||||
|
|
||||||
private final Set<String> servers = new HashSet<>();
|
|
||||||
private final EventPublisher eventPublisher;
|
|
||||||
|
|
||||||
public SseEmitter subscribe(String server) {
|
|
||||||
servers.add(server);
|
|
||||||
return eventPublisher.subscribe(server, this::handleSessionEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleSessionEnd(String key, Instant sessionStart, Instant sessionEnd) {
|
|
||||||
log.info("Server session ended for {}. Active from {} for {}", key, sessionStart, Duration.between(sessionStart, sessionEnd));
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean sendMessage(String server, String channel, String json) {
|
|
||||||
if (!servers.contains(server)) {
|
|
||||||
log.warn("Server {} is not connected, not sending message", server);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
eventPublisher.sendToUser(server, channel, json);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
package com.alttd.altitudeweb.services.chat.to_server.data;
|
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Builder
|
|
||||||
@Getter
|
|
||||||
public class ChatFromWeb {
|
|
||||||
|
|
||||||
private final UUID sender;
|
|
||||||
private final String message;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package com.alttd.altitudeweb.services.discord;
|
package com.alttd.altitudeweb.services.discord;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
import com.alttd.altitudeweb.database.Databases;
|
||||||
import com.alttd.altitudeweb.database.discord.AppealList;
|
|
||||||
import com.alttd.altitudeweb.database.discord.AppealListMapper;
|
|
||||||
import com.alttd.altitudeweb.database.discord.OutputChannel;
|
import com.alttd.altitudeweb.database.discord.OutputChannel;
|
||||||
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
|
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
|
||||||
import com.alttd.altitudeweb.database.litebans.HistoryCountMapper;
|
import com.alttd.altitudeweb.database.litebans.HistoryCountMapper;
|
||||||
|
|
@ -10,12 +8,7 @@ import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
||||||
import com.alttd.altitudeweb.database.litebans.HistoryType;
|
import com.alttd.altitudeweb.database.litebans.HistoryType;
|
||||||
import com.alttd.altitudeweb.database.litebans.UserType;
|
import com.alttd.altitudeweb.database.litebans.UserType;
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
|
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
|
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal;
|
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppealMapper;
|
|
||||||
import com.alttd.altitudeweb.setup.Connection;
|
import com.alttd.altitudeweb.setup.Connection;
|
||||||
import com.alttd.webinterface.appeals.AppealSender;
|
|
||||||
import com.alttd.webinterface.objects.MessageForEmbed;
|
|
||||||
import com.alttd.webinterface.send_message.DiscordSender;
|
import com.alttd.webinterface.send_message.DiscordSender;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
@ -23,7 +16,8 @@ import org.springframework.stereotype.Service;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.*;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
|
@ -32,67 +26,19 @@ public class AppealDiscord {
|
||||||
|
|
||||||
private static final String OUTPUT_TYPE = "APPEAL";
|
private static final String OUTPUT_TYPE = "APPEAL";
|
||||||
|
|
||||||
public void sendAppealToDiscord(DiscordAppeal discordAppeal) {
|
|
||||||
CompletableFuture<List<OutputChannel>> channelsFuture = getChannelListFuture();
|
|
||||||
List<OutputChannel> channels = channelsFuture.join();
|
|
||||||
if (channels.isEmpty()) {
|
|
||||||
log.warn("Discord appeal: No Discord output channels found for type {}. Skipping Discord send.", OUTPUT_TYPE);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String createdAt = formatInstant(discordAppeal.createdAt());
|
|
||||||
List<DiscordSender.EmbedField> fields = new ArrayList<>();
|
|
||||||
|
|
||||||
// Group: User
|
|
||||||
fields.add(new DiscordSender.EmbedField(
|
|
||||||
"User",
|
|
||||||
"""
|
|
||||||
Discord Username: `%s`
|
|
||||||
Discord id: %s
|
|
||||||
MC UUID: %s
|
|
||||||
Submitted: %s
|
|
||||||
""".formatted(
|
|
||||||
safe(discordAppeal.discordUsername()),
|
|
||||||
discordAppeal.discordId(),
|
|
||||||
safe(String.valueOf(discordAppeal.uuid())),
|
|
||||||
createdAt
|
|
||||||
),
|
|
||||||
false
|
|
||||||
));
|
|
||||||
|
|
||||||
Optional<Long> optionalAssignedTo = assignAppeal();
|
|
||||||
if (optionalAssignedTo.isPresent()) {
|
|
||||||
Long assignedTo = optionalAssignedTo.get();
|
|
||||||
fields.add(new DiscordSender.EmbedField(
|
|
||||||
"Assigned to",
|
|
||||||
"Assigned to: <@" + assignedTo + ">",
|
|
||||||
true
|
|
||||||
));
|
|
||||||
assignDiscordAppealTo(discordAppeal.id(), assignedTo);
|
|
||||||
} else {
|
|
||||||
fields.add(new DiscordSender.EmbedField(
|
|
||||||
"Assigned to",
|
|
||||||
"Assigned to: None (failed to assign)",
|
|
||||||
true
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
String description = safe(discordAppeal.reason());
|
|
||||||
|
|
||||||
List<Long> channelIds = channels.stream()
|
|
||||||
.map(OutputChannel::channel)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// colorRgb = null (use default), timestamp = appeal.createdAt if available
|
|
||||||
Instant timestamp = discordAppeal.createdAt() != null ? discordAppeal.createdAt() : Instant.now();
|
|
||||||
MessageForEmbed newAppealSubmitted = new MessageForEmbed(
|
|
||||||
"New Discord Appeal Submitted", description, fields, null, timestamp, null);
|
|
||||||
AppealSender.getInstance().sendAppeal(channelIds, newAppealSubmitted, optionalAssignedTo.orElse(0L));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void sendAppealToDiscord(Appeal appeal, HistoryRecord history) {
|
public void sendAppealToDiscord(Appeal appeal, HistoryRecord history) {
|
||||||
// Fetch channels
|
// Fetch channels
|
||||||
CompletableFuture<List<OutputChannel>> channelsFuture = getChannelListFuture();
|
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
|
||||||
|
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
|
||||||
|
try {
|
||||||
|
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
|
||||||
|
.getChannelsWithOutputType(OUTPUT_TYPE);
|
||||||
|
channelsFuture.complete(channels);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
|
||||||
|
channelsFuture.complete(new ArrayList<>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
CompletableFuture<Integer> bansF = getCountAsync(HistoryType.BAN, appeal.uuid());
|
CompletableFuture<Integer> bansF = getCountAsync(HistoryType.BAN, appeal.uuid());
|
||||||
CompletableFuture<Integer> mutesF = getCountAsync(HistoryType.MUTE, appeal.uuid());
|
CompletableFuture<Integer> mutesF = getCountAsync(HistoryType.MUTE, appeal.uuid());
|
||||||
|
|
@ -118,8 +64,9 @@ public class AppealDiscord {
|
||||||
// Group: User
|
// Group: User
|
||||||
fields.add(new DiscordSender.EmbedField(
|
fields.add(new DiscordSender.EmbedField(
|
||||||
"User",
|
"User",
|
||||||
"Username: `" + safe(appeal.username()) + "`\n" +
|
"Username: " + safe(appeal.username()) + "\n" +
|
||||||
"UUID: " + safe(String.valueOf(appeal.uuid())) + "\n" +
|
"UUID: " + safe(String.valueOf(appeal.uuid())) + "\n" +
|
||||||
|
"Email: " + safe(appeal.email()) + "\n" +
|
||||||
"Submitted: " + createdAt,
|
"Submitted: " + createdAt,
|
||||||
false
|
false
|
||||||
));
|
));
|
||||||
|
|
@ -141,22 +88,6 @@ public class AppealDiscord {
|
||||||
"Kicks: " + kicks,
|
"Kicks: " + kicks,
|
||||||
true
|
true
|
||||||
));
|
));
|
||||||
Optional<Long> optionalAssignedTo = assignAppeal();
|
|
||||||
if (optionalAssignedTo.isPresent()) {
|
|
||||||
Long assignedTo = optionalAssignedTo.get();
|
|
||||||
fields.add(new DiscordSender.EmbedField(
|
|
||||||
"Assigned to",
|
|
||||||
"Assigned to: <@" + assignedTo + ">",
|
|
||||||
true
|
|
||||||
));
|
|
||||||
assignMinecraftAppealTo(appeal.id(), assignedTo);
|
|
||||||
} else {
|
|
||||||
fields.add(new DiscordSender.EmbedField(
|
|
||||||
"Assigned to",
|
|
||||||
"Assigned to: None (failed to assign)",
|
|
||||||
true
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
String description = safe(appeal.reason());
|
String description = safe(appeal.reason());
|
||||||
|
|
||||||
|
|
@ -166,44 +97,15 @@ public class AppealDiscord {
|
||||||
|
|
||||||
// colorRgb = null (use default), timestamp = appeal.createdAt if available
|
// colorRgb = null (use default), timestamp = appeal.createdAt if available
|
||||||
Instant timestamp = appeal.createdAt() != null ? appeal.createdAt() : Instant.now();
|
Instant timestamp = appeal.createdAt() != null ? appeal.createdAt() : Instant.now();
|
||||||
MessageForEmbed newAppealSubmitted = new MessageForEmbed(
|
DiscordSender.getInstance().sendEmbedToChannels(
|
||||||
"New Appeal Submitted", description, fields, null, timestamp, null);
|
channelIds,
|
||||||
AppealSender.getInstance().sendAppeal(channelIds, newAppealSubmitted, optionalAssignedTo.orElse(0L));
|
"New Appeal Submitted",
|
||||||
}
|
description,
|
||||||
|
fields,
|
||||||
private static CompletableFuture<List<OutputChannel>> getChannelListFuture() {
|
null,
|
||||||
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
|
timestamp,
|
||||||
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
|
null
|
||||||
try {
|
);
|
||||||
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
|
|
||||||
.getChannelsWithOutputType(OUTPUT_TYPE);
|
|
||||||
channelsFuture.complete(channels);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
|
|
||||||
channelsFuture.complete(new ArrayList<>());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return channelsFuture;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void assignMinecraftAppealTo(UUID appealId, Long assignedTo) {
|
|
||||||
Connection.getConnection(Databases.DEFAULT).runQuery(sql -> {
|
|
||||||
try {
|
|
||||||
sql.getMapper(AppealMapper.class).assignAppeal(appealId, assignedTo);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to assign appeal to {}", assignedTo, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void assignDiscordAppealTo(UUID appealId, Long assignedTo) {
|
|
||||||
Connection.getConnection(Databases.DEFAULT).runQuery(sql -> {
|
|
||||||
try {
|
|
||||||
sql.getMapper(DiscordAppealMapper.class).assignDiscordAppeal(appealId, assignedTo);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to assign appeal to {}", assignedTo, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private CompletableFuture<Integer> getCountAsync(HistoryType type, java.util.UUID uuid) {
|
private CompletableFuture<Integer> getCountAsync(HistoryType type, java.util.UUID uuid) {
|
||||||
|
|
@ -230,50 +132,4 @@ public class AppealDiscord {
|
||||||
return instant.atZone(ZoneId.of("UTC"))
|
return instant.atZone(ZoneId.of("UTC"))
|
||||||
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
|
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional<Long> assignAppeal() {
|
|
||||||
CompletableFuture<Long> assignToCompletableFuture = new CompletableFuture<>();
|
|
||||||
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
|
|
||||||
try {
|
|
||||||
AppealListMapper mapper = sql.getMapper(AppealListMapper.class);
|
|
||||||
List<AppealList> appealList = mapper
|
|
||||||
.getAppealList();
|
|
||||||
|
|
||||||
|
|
||||||
if (appealList.isEmpty()) {
|
|
||||||
log.warn("No appeal lists found. Skipping assignment.");
|
|
||||||
assignToCompletableFuture.complete(0L);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Optional<AppealList> optionalAssignTo = appealList
|
|
||||||
.stream()
|
|
||||||
.filter(AppealList::next).findFirst();
|
|
||||||
AppealList assignTo = optionalAssignTo.orElseGet(appealList::getFirst);
|
|
||||||
assignToCompletableFuture.complete(assignTo.userId());
|
|
||||||
|
|
||||||
try {
|
|
||||||
Optional<AppealList> optionalNextAppealList = appealList
|
|
||||||
.stream()
|
|
||||||
.filter(entry -> entry.userId() > assignTo.userId())
|
|
||||||
.min(Comparator.comparing(AppealList::userId));
|
|
||||||
AppealList nextAppealList = optionalNextAppealList.orElse(appealList.stream()
|
|
||||||
.min(Comparator.comparing(AppealList::userId))
|
|
||||||
.orElse(assignTo));
|
|
||||||
mapper.updateNext(assignTo.userId(), false);
|
|
||||||
mapper.updateNext(nextAppealList.userId(), true);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to assign next appeal", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to load appeal list", e);
|
|
||||||
assignToCompletableFuture.complete(0L);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Long assignTo = assignToCompletableFuture.join();
|
|
||||||
if (assignTo.equals(0L)) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
return Optional.of(assignTo);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import com.alttd.altitudeweb.database.discord.OutputChannel;
|
||||||
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
|
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
|
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
|
||||||
import com.alttd.altitudeweb.setup.Connection;
|
import com.alttd.altitudeweb.setup.Connection;
|
||||||
import com.alttd.webinterface.objects.MessageForEmbed;
|
|
||||||
import com.alttd.webinterface.send_message.DiscordSender;
|
import com.alttd.webinterface.send_message.DiscordSender;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
@ -47,8 +46,8 @@ public class StaffApplicationDiscord {
|
||||||
List<DiscordSender.EmbedField> fields = new ArrayList<>();
|
List<DiscordSender.EmbedField> fields = new ArrayList<>();
|
||||||
fields.add(new DiscordSender.EmbedField(
|
fields.add(new DiscordSender.EmbedField(
|
||||||
"Applicant",
|
"Applicant",
|
||||||
"Username: `" + safe(username) + "`\n" +
|
"Username: " + safe(username) + "\n" +
|
||||||
"Discord: `" + safe(application.discordUsername()) + "`\n" +
|
"Discord: " + safe(application.discordUsername()) + "\n" +
|
||||||
"Email: " + safe(application.email()) + "\n" +
|
"Email: " + safe(application.email()) + "\n" +
|
||||||
"Age: " + safe(String.valueOf(application.age())) + "\n" +
|
"Age: " + safe(String.valueOf(application.age())) + "\n" +
|
||||||
"Meets reqs: " + (application.meetsRequirements() != null && application.meetsRequirements()),
|
"Meets reqs: " + (application.meetsRequirements() != null && application.meetsRequirements()),
|
||||||
|
|
@ -80,15 +79,16 @@ public class StaffApplicationDiscord {
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
Instant timestamp = application.createdAt() != null ? application.createdAt() : Instant.now();
|
Instant timestamp = application.createdAt() != null ? application.createdAt() : Instant.now();
|
||||||
MessageForEmbed messageForEmbed = new MessageForEmbed(
|
DiscordSender.getInstance().sendEmbedToChannels(
|
||||||
|
channelIds,
|
||||||
"New Staff Application Submitted",
|
"New Staff Application Submitted",
|
||||||
"Join date: " + (application.joinDate() != null ? application.joinDate().toString() : "unknown") +
|
"Join date: " + (application.joinDate() != null ? application.joinDate().toString() : "unknown") +
|
||||||
"\nSubmitted: " + formatInstant(timestamp),
|
"\nSubmitted: " + formatInstant(timestamp),
|
||||||
fields,
|
fields,
|
||||||
null,
|
null,
|
||||||
timestamp,
|
timestamp,
|
||||||
null);
|
null
|
||||||
DiscordSender.getInstance().sendEmbedWithThreadToChannels(channelIds, messageForEmbed, "Staff Application");
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String safe(String s) {
|
private String safe(String s) {
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
package com.alttd.altitudeweb.services.forms;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
|
||||||
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
|
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppealMapper;
|
|
||||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
|
|
||||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
|
||||||
import com.alttd.altitudeweb.mappers.BannedUserToBannedUserDtoMapper;
|
|
||||||
import com.alttd.altitudeweb.mappers.DiscordAppealDtoToDiscordAppealMapper;
|
|
||||||
import com.alttd.altitudeweb.model.BannedUserResponseDto;
|
|
||||||
import com.alttd.altitudeweb.model.DiscordAppealDto;
|
|
||||||
import com.alttd.altitudeweb.model.FormResponseDto;
|
|
||||||
import com.alttd.altitudeweb.services.discord.AppealDiscord;
|
|
||||||
import com.alttd.altitudeweb.services.mail.AppealMail;
|
|
||||||
import com.alttd.altitudeweb.setup.Connection;
|
|
||||||
import com.alttd.webinterface.appeals.BannedUser;
|
|
||||||
import com.alttd.webinterface.appeals.DiscordAppealDiscord;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DiscordAppeal {
|
|
||||||
|
|
||||||
private final BannedUserToBannedUserDtoMapper bannedUserToBannedUserDtoMapper;
|
|
||||||
private final DiscordAppealDtoToDiscordAppealMapper discordAppealDtoToDiscordAppealMapper;
|
|
||||||
private final AuthenticatedUuid authenticatedUuid;
|
|
||||||
private final AppealDiscord appealDiscord;
|
|
||||||
private final AppealMail appealMail;
|
|
||||||
|
|
||||||
public BannedUserResponseDto getBannedUser(Long discordId) {
|
|
||||||
DiscordAppealDiscord discordAppeal = DiscordAppealDiscord.getInstance();
|
|
||||||
Optional<BannedUser> join = discordAppeal.getBannedUser(discordId).join();
|
|
||||||
if (join.isEmpty()) {
|
|
||||||
return new BannedUserResponseDto(false);
|
|
||||||
}
|
|
||||||
BannedUserResponseDto bannedUserResponseDto = new BannedUserResponseDto(true);
|
|
||||||
bannedUserResponseDto.setBannedUser(bannedUserToBannedUserDtoMapper.map(join.get()));
|
|
||||||
return bannedUserResponseDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
public FormResponseDto submitAppeal(DiscordAppealDto discordAppealDto) {
|
|
||||||
DiscordAppealDiscord discordAppealDiscord = DiscordAppealDiscord.getInstance();
|
|
||||||
long discordId = Long.parseLong(discordAppealDto.getDiscordId());
|
|
||||||
Optional<BannedUser> join = discordAppealDiscord.getBannedUser(discordId).join();
|
|
||||||
if (join.isEmpty()) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
|
||||||
}
|
|
||||||
BannedUser bannedUser = join.get();
|
|
||||||
|
|
||||||
Optional<UUID> optionalUUID = authenticatedUuid.tryGetAuthenticatedUserUuid();
|
|
||||||
if (optionalUUID.isEmpty()) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated");
|
|
||||||
}
|
|
||||||
UUID uuid = optionalUUID.get();
|
|
||||||
CompletableFuture<com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal> appealCompletableFuture = new CompletableFuture<>();
|
|
||||||
|
|
||||||
Connection.getConnection(Databases.DEFAULT)
|
|
||||||
.runQuery(sqlSession -> {
|
|
||||||
log.debug("Loading history by id");
|
|
||||||
try {
|
|
||||||
com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal discordAppealRecord = discordAppealDtoToDiscordAppealMapper
|
|
||||||
.map(discordAppealDto, uuid, bannedUser.name());
|
|
||||||
sqlSession.getMapper(DiscordAppealMapper.class).createDiscordAppeal(discordAppealRecord);
|
|
||||||
appealCompletableFuture.complete(discordAppealRecord);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to load history count", e);
|
|
||||||
appealCompletableFuture.completeExceptionally(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal discordAppeal = appealCompletableFuture.join();
|
|
||||||
|
|
||||||
CompletableFuture<Optional<EmailVerification>> emailVerificationCompletableFuture = new CompletableFuture<>();
|
|
||||||
Connection.getConnection(Databases.DEFAULT)
|
|
||||||
.runQuery(sqlSession -> {
|
|
||||||
log.debug("Retrieving mail by uuid and address");
|
|
||||||
|
|
||||||
EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class)
|
|
||||||
.findByUserAndEmail(discordAppeal.uuid(), discordAppeal.email().toLowerCase());
|
|
||||||
emailVerificationCompletableFuture.complete(Optional.ofNullable(verifiedMail));
|
|
||||||
});
|
|
||||||
Optional<EmailVerification> optionalEmailVerification = emailVerificationCompletableFuture.join();
|
|
||||||
|
|
||||||
if (optionalEmailVerification.isEmpty()) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid mail");
|
|
||||||
}
|
|
||||||
EmailVerification emailVerification = optionalEmailVerification.get();
|
|
||||||
if (!emailVerification.verified()) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Mail not verified");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
appealDiscord.sendAppealToDiscord(discordAppeal);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to send appeal {} to Discord", discordAppeal.id(), e);
|
|
||||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to send appeal to Discord");
|
|
||||||
}
|
|
||||||
//TODO verify mail
|
|
||||||
String username = getUsername(uuid);
|
|
||||||
appealMail.sendAppealNotification(discordAppeal, username);
|
|
||||||
|
|
||||||
Connection.getConnection(Databases.DEFAULT)
|
|
||||||
.runQuery(sqlSession -> {
|
|
||||||
log.debug("Marking appeal {} as sent", discordAppeal.id());
|
|
||||||
sqlSession.getMapper(DiscordAppealMapper.class)
|
|
||||||
.markDiscordAppealAsSent(discordAppeal.id());
|
|
||||||
});
|
|
||||||
return new FormResponseDto(
|
|
||||||
discordAppeal.id().toString(),
|
|
||||||
"Your appeal has been submitted. You will be notified when it has been reviewed.",
|
|
||||||
true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getUsername(UUID uuid) {
|
|
||||||
CompletableFuture<String> usernameFuture = new CompletableFuture<>();
|
|
||||||
Connection.getConnection(Databases.LUCK_PERMS)
|
|
||||||
.runQuery(sqlSession -> {
|
|
||||||
log.debug("Loading username for uuid {}", uuid);
|
|
||||||
try {
|
|
||||||
String username = sqlSession.getMapper(UUIDUsernameMapper.class).getUsernameFromUUID(uuid.toString());
|
|
||||||
usernameFuture.complete(username);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to load username for uuid {}", uuid, e);
|
|
||||||
usernameFuture.completeExceptionally(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return usernameFuture.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.alttd.altitudeweb.services.limits;
|
package com.alttd.altitudeweb.services.limits;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -17,8 +16,6 @@ import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Aspect
|
@Aspect
|
||||||
@Component
|
@Component
|
||||||
|
|
@ -27,7 +24,6 @@ import java.util.UUID;
|
||||||
public class RateLimitAspect {
|
public class RateLimitAspect {
|
||||||
|
|
||||||
private final InMemoryRateLimiterService rateLimiterService;
|
private final InMemoryRateLimiterService rateLimiterService;
|
||||||
private final AuthenticatedUuid authenticatedUuid;
|
|
||||||
|
|
||||||
@Around("""
|
@Around("""
|
||||||
@annotation(com.alttd.altitudeweb.services.limits.RateLimit)
|
@annotation(com.alttd.altitudeweb.services.limits.RateLimit)
|
||||||
|
|
@ -41,6 +37,7 @@ public class RateLimitAspect {
|
||||||
HttpServletRequest request = requestAttributes.getRequest();
|
HttpServletRequest request = requestAttributes.getRequest();
|
||||||
HttpServletResponse response = requestAttributes.getResponse();
|
HttpServletResponse response = requestAttributes.getResponse();
|
||||||
|
|
||||||
|
String clientIp = request.getRemoteAddr();
|
||||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||||
Method method = signature.getMethod();
|
Method method = signature.getMethod();
|
||||||
|
|
||||||
|
|
@ -57,12 +54,7 @@ public class RateLimitAspect {
|
||||||
Duration duration = Duration.ofSeconds(rateLimit.timeUnit().toSeconds(rateLimit.timeValue()));
|
Duration duration = Duration.ofSeconds(rateLimit.timeUnit().toSeconds(rateLimit.timeValue()));
|
||||||
String customKey = rateLimit.key();
|
String customKey = rateLimit.key();
|
||||||
|
|
||||||
Optional<UUID> optionalUUID = authenticatedUuid.tryGetAuthenticatedUserUuid();
|
String key = clientIp + "-" + (customKey.isEmpty() ? method.getName() : customKey);
|
||||||
if (optionalUUID.isEmpty()) {
|
|
||||||
return joinPoint.proceed();
|
|
||||||
}
|
|
||||||
UUID uuid = optionalUUID.get();
|
|
||||||
String key = uuid + "-" + (customKey.isEmpty() ? method.getName() : customKey);
|
|
||||||
|
|
||||||
boolean allowed = rateLimiterService.tryAcquire(key, limit, duration);
|
boolean allowed = rateLimiterService.tryAcquire(key, limit, duration);
|
||||||
|
|
||||||
|
|
@ -75,7 +67,7 @@ public class RateLimitAspect {
|
||||||
|
|
||||||
return joinPoint.proceed();
|
return joinPoint.proceed();
|
||||||
} else {
|
} else {
|
||||||
log.warn("Rate limit exceeded for uuid: {}, endpoint: {}", uuid, request.getRequestURI());
|
log.warn("Rate limit exceeded for IP: {}, endpoint: {}", clientIp, request.getRequestURI());
|
||||||
|
|
||||||
Duration nextResetTime = rateLimiterService.getNextResetTime(key, duration);
|
Duration nextResetTime = rateLimiterService.getNextResetTime(key, duration);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package com.alttd.altitudeweb.services.mail;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
|
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal;
|
|
||||||
import jakarta.mail.MessagingException;
|
import jakarta.mail.MessagingException;
|
||||||
import jakarta.mail.internet.MimeMessage;
|
import jakarta.mail.internet.MimeMessage;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -30,20 +29,6 @@ public class AppealMail {
|
||||||
|
|
||||||
private static final String APPEAL_EMAIL = "appeal@alttd.com";
|
private static final String APPEAL_EMAIL = "appeal@alttd.com";
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends an email notification about the appeal to both the user and the appeals team.
|
|
||||||
*
|
|
||||||
* @param appeal The appeal object containing all necessary information
|
|
||||||
*/
|
|
||||||
public void sendAppealNotification(DiscordAppeal appeal, String username) {
|
|
||||||
try {
|
|
||||||
sendEmailToAppealsTeam(appeal, username);
|
|
||||||
|
|
||||||
log.info("Discord Appeal notification emails sent successfully for appeal ID: {}", appeal.id());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to send discord appeal notification emails for appeal ID: {}", appeal.id(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Sends an email notification about the appeal to both the user and the appeals team.
|
* Sends an email notification about the appeal to both the user and the appeals team.
|
||||||
*
|
*
|
||||||
|
|
@ -81,31 +66,4 @@ public class AppealMail {
|
||||||
mailSender.send(message);
|
mailSender.send(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendEmailToAppealsTeam(DiscordAppeal appeal, String username) throws MessagingException {
|
|
||||||
MimeMessage message = mailSender.createMimeMessage();
|
|
||||||
MimeMessageHelper helper = getAppealMimeMessageHelper(appeal, message);
|
|
||||||
|
|
||||||
Context context = new Context();
|
|
||||||
context.setVariable("appeal", appeal);
|
|
||||||
context.setVariable("createdAt", appeal.createdAt()
|
|
||||||
.atZone(ZoneId.of("UTC"))
|
|
||||||
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'")));
|
|
||||||
|
|
||||||
context.setVariable("minecraftName", username);
|
|
||||||
String content = templateEngine.process("discord-appeal-email", context);
|
|
||||||
|
|
||||||
helper.setText(content, true);
|
|
||||||
mailSender.send(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private MimeMessageHelper getAppealMimeMessageHelper(DiscordAppeal appeal, MimeMessage message) throws MessagingException {
|
|
||||||
MimeMessageHelper helper = new MimeMessageHelper(message, true);
|
|
||||||
|
|
||||||
helper.setFrom(fromEmail);
|
|
||||||
helper.setTo(APPEAL_EMAIL);
|
|
||||||
helper.setReplyTo(appeal.email());
|
|
||||||
helper.setSubject("New Appeal Submitted - " + appeal.discordUsername());
|
|
||||||
return helper;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
package com.alttd.altitudeweb.services.site;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
|
||||||
import com.alttd.altitudeweb.database.luckperms.Player;
|
|
||||||
import com.alttd.altitudeweb.database.luckperms.PlayerWithGroup;
|
|
||||||
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
|
|
||||||
import com.alttd.altitudeweb.database.proxyplaytime.StaffPlaytimeMapper;
|
|
||||||
import com.alttd.altitudeweb.database.proxyplaytime.StaffPt;
|
|
||||||
import com.alttd.altitudeweb.mappers.StaffPtToStaffPlaytimeMapper;
|
|
||||||
import com.alttd.altitudeweb.model.StaffPlaytimeDto;
|
|
||||||
import com.alttd.altitudeweb.setup.Connection;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class StaffPtService {
|
|
||||||
private final static HashMap<String, String> STAFF_GROUPS_MAP = new HashMap<>();
|
|
||||||
private final static String STAFF_GROUPS;
|
|
||||||
static {
|
|
||||||
STAFF_GROUPS_MAP.put("group.owner", "Owner");
|
|
||||||
STAFF_GROUPS_MAP.put("group.manager", "Manager");
|
|
||||||
STAFF_GROUPS_MAP.put("group.admin", "Admin");
|
|
||||||
STAFF_GROUPS_MAP.put("group.headmod", "Head Mod");
|
|
||||||
STAFF_GROUPS_MAP.put("group.moderator", "Moderator");
|
|
||||||
STAFF_GROUPS_MAP.put("group.trainee", "Trainee");
|
|
||||||
STAFF_GROUPS_MAP.put("group.developer", "Developer");
|
|
||||||
STAFF_GROUPS = STAFF_GROUPS_MAP.keySet().stream()
|
|
||||||
.map(group -> "'" + group + "'")
|
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
}
|
|
||||||
private final StaffPtToStaffPlaytimeMapper staffPtToStaffPlaytimeMapper;
|
|
||||||
|
|
||||||
public Optional<List<StaffPlaytimeDto>> getStaffPlaytime(Instant from, Instant to) {
|
|
||||||
CompletableFuture<List<PlayerWithGroup>> staffMembersFuture = new CompletableFuture<>();
|
|
||||||
CompletableFuture<List<StaffPt>> staffPlaytimeFuture = new CompletableFuture<>();
|
|
||||||
Connection.getConnection(Databases.LUCK_PERMS)
|
|
||||||
.runQuery(sqlSession -> {
|
|
||||||
log.debug("Loading staff members");
|
|
||||||
try {
|
|
||||||
List<PlayerWithGroup> staffMemberList = sqlSession.getMapper(TeamMemberMapper.class)
|
|
||||||
.getTeamMembersOfGroupList(STAFF_GROUPS);
|
|
||||||
staffMembersFuture.complete(staffMemberList);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to load staff members", e);
|
|
||||||
staffMembersFuture.completeExceptionally(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
List<PlayerWithGroup> staffMembers = staffMembersFuture.join().stream()
|
|
||||||
.collect(Collectors.collectingAndThen(
|
|
||||||
Collectors.toMap(PlayerWithGroup::uuid, player -> player, (player1, player2) -> player1),
|
|
||||||
m -> new ArrayList<>(m.values())));
|
|
||||||
Connection.getConnection(Databases.PROXY_PLAYTIME)
|
|
||||||
.runQuery(sqlSession -> {
|
|
||||||
String staffUUIDs = staffMembers.stream()
|
|
||||||
.map(PlayerWithGroup::uuid)
|
|
||||||
.map(uuid -> "'" + uuid + "'")
|
|
||||||
.collect(Collectors.joining(","));
|
|
||||||
log.debug("Loading staff playtime for group");
|
|
||||||
try {
|
|
||||||
List<StaffPt> sessionsDuring = sqlSession.getMapper(StaffPlaytimeMapper.class)
|
|
||||||
.getSessionsDuring(from.toEpochMilli(), to.toEpochMilli(), staffUUIDs);
|
|
||||||
staffPlaytimeFuture.complete(sessionsDuring);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to load staff playtime", e);
|
|
||||||
staffPlaytimeFuture.completeExceptionally(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
List<StaffPt> join = staffPlaytimeFuture.join();
|
|
||||||
HashMap<String, String> staffGroupsMap = new HashMap<>(STAFF_GROUPS_MAP);
|
|
||||||
return Optional.of(staffPtToStaffPlaytimeMapper.map(join, staffMembers, from.toEpochMilli(), to.toEpochMilli(), staffGroupsMap));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,7 @@ database.user=${DB_USER:root}
|
||||||
database.password=${DB_PASSWORD:root}
|
database.password=${DB_PASSWORD:root}
|
||||||
cors.allowed-origins=${CORS:https://alttd.com}
|
cors.allowed-origins=${CORS:https://alttd.com}
|
||||||
login.secret=${LOGIN_SECRET:SET_TOKEN}
|
login.secret=${LOGIN_SECRET:SET_TOKEN}
|
||||||
particles.file_path=${PARTICLE_FILE_PATH:${user.home}/.altitudeweb/particles}
|
particles.file_path=${user.home}/.altitudeweb/particles
|
||||||
notification.server.url=${SERVER_IP:10.0.0.107}:${SERVER_PORT:8080}
|
notification.server.url=${SERVER_IP:10.0.0.107}:${SERVER_PORT:8080}
|
||||||
my-server.address=${SERVER_ADDRESS:https://alttd.com}
|
my-server.address=${SERVER_ADDRESS:https://alttd.com}
|
||||||
logging.level.com.alttd.altitudeweb=INFO
|
logging.level.com.alttd.altitudeweb=INFO
|
||||||
|
|
@ -18,10 +18,3 @@ 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}
|
|
||||||
chat.allowed-servers=${ALLOWED_SERVERS:bayou,lobby,creative}
|
|
||||||
management.endpoints.web.exposure.include=mappings
|
|
||||||
server.forward-headers-strategy=native
|
|
||||||
server.tomcat.remoteip.remote-ip-header=CF-Connecting-IP
|
|
||||||
server.tomcat.remoteip.trusted-proxies=10\\.0\\.0\\.103
|
|
||||||
altitudeweb.token=${TOKEN:invalid-token}
|
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,8 @@
|
||||||
<div class="columnContainer">
|
<div class="columnContainer">
|
||||||
<div>
|
<div>
|
||||||
<h2>Appeal:</h2>
|
<h2>Appeal:</h2>
|
||||||
<p th:text="${appeal.reason}">appeal</p>
|
<p th:text="${appeal.reason}">Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns:th="http://www.thymeleaf.org" lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Discord Appeal Notification</title>
|
|
||||||
<style>
|
|
||||||
@font-face {
|
|
||||||
font-family: 'minecraft-title';
|
|
||||||
src: url('https://beta.alttd.com/public/fonts/minecraft-title.ttf') format('truetype'),
|
|
||||||
url('https://beta.alttd.com/public/fonts/minecraft-title.eot') format('embedded-opentype'),
|
|
||||||
url('https://beta.alttd.com/public/fonts/minecraft-title.svg') format('svg'),
|
|
||||||
url('https://beta.alttd.com/public/fonts/minecraft-title.woff') format('woff');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'minecraft-text';
|
|
||||||
src: url('https://beta.alttd.com/public/fonts/minecraft-text.ttf') format('truetype'),
|
|
||||||
url('https://beta.alttd.com/public/fonts/minecraft-text.eot') format('embedded-opentype'),
|
|
||||||
url('https://beta.alttd.com/public/fonts/minecraft-text.svg') format('svg'),
|
|
||||||
url('https://beta.alttd.com/public/fonts/minecraft-text.woff') format('woff');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'opensans';
|
|
||||||
src: url('https://beta.alttd.com/public/fonts/opensans.ttf') format('truetype'),
|
|
||||||
url('https://beta.alttd.com/public/fonts/opensans.eot') format('embedded-opentype'),
|
|
||||||
url('https://beta.alttd.com/public/fonts/opensans.svg') format('svg'),
|
|
||||||
url('https://beta.alttd.com/public/fonts/opensans.woff') format('woff');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'opensans-bold';
|
|
||||||
src: url('https://beta.alttd.com/public/fonts/opensans-bold.ttf') format('truetype'),
|
|
||||||
url('https://beta.alttd.com/public/fonts/opensans-bold.eot') format('embedded-opentype'),
|
|
||||||
url('https://beta.alttd.com/public/fonts/opensans-bold.svg') format('svg'),
|
|
||||||
url('https://beta.alttd.com/public/fonts/opensans-bold.woff') format('woff');
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'minecraft-title', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.columnSection {
|
|
||||||
width: 80%;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.columnContainer {
|
|
||||||
flex: 1 1 200px;
|
|
||||||
min-width: 200px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
margin: auto;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style-type: none;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
padding-bottom: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
li, p {
|
|
||||||
font-family: 'opensans', sans-serif;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1150px) {
|
|
||||||
.columnContainer, .columnSection {
|
|
||||||
width: 90%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 690px) {
|
|
||||||
.columnContainer {
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<img id="header-img" src="https://beta.alttd.com/public/img/logos/logo.png" alt="The Altitude Minecraft Server" height="159"
|
|
||||||
width="275">
|
|
||||||
<h1 style="text-align: center;" th:text="'Appeal by ' + ${appeal.discordUsername}">Appeal by Username</h1>
|
|
||||||
<section class="columnSection">
|
|
||||||
<div class="columnContainer">
|
|
||||||
<div>
|
|
||||||
<h2>User information</h2>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Discord Username:</strong> <span th:text="${appeal.discordUsername}">dc username</span></li>
|
|
||||||
<li><strong>UUID:</strong> <span th:text="${appeal.uuid}">uuid</span></li>
|
|
||||||
<li><strong>Minecraft Username:</strong> <span th:text="${minecraftName}">mc username</span></li>
|
|
||||||
<li><strong>Email:</strong> <span th:text="${appeal.email}">email</span></li>
|
|
||||||
<li><strong>Submitted at:</strong> <span th:text="${createdAt}">date</span></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="columnContainer">
|
|
||||||
<div>
|
|
||||||
<h2>Appeal:</h2>
|
|
||||||
<p th:text="${appeal.reason}">appeal</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
package com.alttd.altitudeweb.services.chat;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.controllers.chat.ChatMessage;
|
|
||||||
import com.alttd.altitudeweb.controllers.chat.ChatMessageType;
|
|
||||||
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
|
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
|
|
||||||
import com.alttd.altitudeweb.services.chat.event_publisher.MessageForUser;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
class ChatServiceTest {
|
|
||||||
|
|
||||||
private ChatService chatService;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
EventPublisher eventPublisher = mock(EventPublisher.class);
|
|
||||||
chatService = spy(new ChatService(eventPublisher));
|
|
||||||
ReflectionTestUtils.setField(chatService, "allowedServers", new String[]{"server1"});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testPublicMessageSentToEveryone() {
|
|
||||||
ChatMessage message = ChatMessage.builder()
|
|
||||||
.uuid(UUID.randomUUID())
|
|
||||||
.type(ChatMessageType.PUBLIC)
|
|
||||||
.server("server1")
|
|
||||||
.messageJson("hello")
|
|
||||||
.notBlocked(true)
|
|
||||||
.timestamp(Instant.now())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
chatService.addChatMessage(List.of(message));
|
|
||||||
|
|
||||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
|
||||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
|
||||||
|
|
||||||
MessageForUser messageForUser = captor.getValue();
|
|
||||||
EventUser user = new EventUser(UUID.randomUUID(), List.of(), null);
|
|
||||||
String result = messageForUser.get(user);
|
|
||||||
|
|
||||||
assertTrue(result.contains("hello"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testGacMessageSentOnlyToStaff() {
|
|
||||||
ChatMessage message = ChatMessage.builder()
|
|
||||||
.uuid(UUID.randomUUID())
|
|
||||||
.type(ChatMessageType.GAC)
|
|
||||||
.server("server1")
|
|
||||||
.messageJson("staff chat")
|
|
||||||
.notBlocked(true)
|
|
||||||
.timestamp(Instant.now())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
chatService.addChatMessage(List.of(message));
|
|
||||||
|
|
||||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
|
||||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
|
||||||
|
|
||||||
MessageForUser messageForUser = captor.getValue();
|
|
||||||
|
|
||||||
EventUser regularUser = new EventUser(UUID.randomUUID(), List.of(), null);
|
|
||||||
assertFalse(messageForUser.get(regularUser).contains("staff chat"), "Regular user should not see GAC message");
|
|
||||||
|
|
||||||
EventUser modUser = new EventUser(UUID.randomUUID(), List.of(PermissionClaimDto.MOD.getValue()), null);
|
|
||||||
assertTrue(messageForUser.get(modUser).contains("staff chat"), "MOD should see GAC message");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testMsgMessageSentToSenderAndReceiver() {
|
|
||||||
UUID senderUuid = UUID.randomUUID();
|
|
||||||
UUID receiverUuid = UUID.randomUUID();
|
|
||||||
ChatMessage message = ChatMessage.builder()
|
|
||||||
.uuid(senderUuid)
|
|
||||||
.type(ChatMessageType.MSG)
|
|
||||||
.server("server1")
|
|
||||||
.receiver(receiverUuid.toString())
|
|
||||||
.messageJson("private message")
|
|
||||||
.notBlocked(true)
|
|
||||||
.timestamp(Instant.now())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
chatService.addChatMessage(List.of(message));
|
|
||||||
|
|
||||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
|
||||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
|
||||||
|
|
||||||
MessageForUser messageForUser = captor.getValue();
|
|
||||||
|
|
||||||
EventUser sender = new EventUser(senderUuid, List.of(), null);
|
|
||||||
EventUser receiver = new EventUser(receiverUuid, List.of(), null);
|
|
||||||
EventUser other = new EventUser(UUID.randomUUID(), List.of(), null);
|
|
||||||
|
|
||||||
assertTrue(messageForUser.get(receiver).contains("private message"), "Receiver should see the message");
|
|
||||||
assertFalse(messageForUser.get(other).contains("private message"), "Others should not see private message");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testGlobalMessageSentToEveryone() {
|
|
||||||
ChatMessage message = ChatMessage.builder()
|
|
||||||
.uuid(UUID.randomUUID())
|
|
||||||
.type(ChatMessageType.GLOBAL)
|
|
||||||
.server("server1")
|
|
||||||
.messageJson("global hello")
|
|
||||||
.notBlocked(true)
|
|
||||||
.timestamp(Instant.now())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
chatService.addChatMessage(List.of(message));
|
|
||||||
|
|
||||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
|
||||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
|
||||||
|
|
||||||
MessageForUser messageForUser = captor.getValue();
|
|
||||||
EventUser user = new EventUser(UUID.randomUUID(), List.of(), null);
|
|
||||||
assertTrue(messageForUser.get(user).contains("global hello"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testBlockedMessageOnlySentToHeadMod() {
|
|
||||||
ChatMessage message = ChatMessage.builder()
|
|
||||||
.uuid(UUID.randomUUID())
|
|
||||||
.type(ChatMessageType.PUBLIC)
|
|
||||||
.server("server1")
|
|
||||||
.messageJson("blocked message")
|
|
||||||
.notBlocked(false)
|
|
||||||
.timestamp(Instant.now())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
chatService.addChatMessage(List.of(message));
|
|
||||||
|
|
||||||
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
|
|
||||||
verify(chatService).sendToUsers(anyString(), captor.capture());
|
|
||||||
|
|
||||||
MessageForUser messageForUser = captor.getValue();
|
|
||||||
EventUser regularUser = new EventUser(UUID.randomUUID(), List.of(), null);
|
|
||||||
EventUser headModUser = new EventUser(UUID.randomUUID(), List.of(PermissionClaimDto.HEAD_MOD.getValue()), null);
|
|
||||||
|
|
||||||
assertFalse(messageForUser.get(regularUser).contains("blocked message"));
|
|
||||||
assertTrue(messageForUser.get(headModUser).contains("blocked message"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,9 +8,7 @@ public enum Databases {
|
||||||
LUCK_PERMS("luckperms"),
|
LUCK_PERMS("luckperms"),
|
||||||
LITE_BANS("litebans"),
|
LITE_BANS("litebans"),
|
||||||
DISCORD("discordLink"),
|
DISCORD("discordLink"),
|
||||||
PROXY_PLAYTIME("proxyplaytime"),
|
VOTING_PLUGIN("votingplugin");
|
||||||
VOTING_PLUGIN("votingplugin"),
|
|
||||||
CHAT("chat");
|
|
||||||
|
|
||||||
private final String internalName;
|
private final String internalName;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.chat;
|
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Getter;
|
|
||||||
|
|
||||||
import java.sql.Timestamp;
|
|
||||||
|
|
||||||
@Builder
|
|
||||||
@Getter
|
|
||||||
public class ChatLogDao {
|
|
||||||
|
|
||||||
private final String uuid;
|
|
||||||
private final Timestamp timeStamp;
|
|
||||||
private final String server;
|
|
||||||
private final String type;
|
|
||||||
private final String channel;
|
|
||||||
private final String receiver;
|
|
||||||
private final String miniMessage;
|
|
||||||
private final boolean blocked;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.chat;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface ChatLogMapper {
|
|
||||||
|
|
||||||
@Select("""
|
|
||||||
SELECT uuid, time_stamp, server, type, channel, receiver, mini_message, blocked
|
|
||||||
FROM chat_log
|
|
||||||
WHERE mini_message IS NOT NULL
|
|
||||||
AND time_stamp >= #{since}
|
|
||||||
""")
|
|
||||||
List<ChatLogDao> getChatLogs(@Param("since") long since);
|
|
||||||
|
|
||||||
@Select("""
|
|
||||||
SELECT party_id FROM chat_users
|
|
||||||
WHERE uuid = #{uuid}
|
|
||||||
""")
|
|
||||||
Optional<Integer> getPartyId(@Param("uuid") String uuid);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.chat;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface NicknameMapper {
|
|
||||||
@Select("""
|
|
||||||
SELECT nickname
|
|
||||||
FROM nicknames
|
|
||||||
WHERE uuid = #{uuid}
|
|
||||||
""")
|
|
||||||
Optional<String> getNicknameFromUUID(@Param("uuid") String uuid);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.chat;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface PartyMapper {
|
|
||||||
|
|
||||||
@Select("""
|
|
||||||
SELECT party_name FROM parties
|
|
||||||
WHERE id = #{id}
|
|
||||||
""")
|
|
||||||
Optional<String> getPartyName(@Param("id") int id);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.discord;
|
|
||||||
|
|
||||||
public record AppealList(Long userId, boolean next) {
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.discord;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
import org.apache.ibatis.annotations.Update;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public interface AppealListMapper {
|
|
||||||
|
|
||||||
@Select("""
|
|
||||||
SELECT userId, next
|
|
||||||
FROM appeal_list
|
|
||||||
ORDER BY userId;
|
|
||||||
""")
|
|
||||||
List<AppealList> getAppealList();
|
|
||||||
|
|
||||||
@Update("""
|
|
||||||
UPDATE appeal_list
|
|
||||||
SET next = #{next}
|
|
||||||
WHERE userId = #{userId}
|
|
||||||
""")
|
|
||||||
void updateNext(@Param("userId") long userId, @Param("next") boolean next);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -48,7 +48,7 @@ public interface EditHistoryMapper {
|
||||||
case BAN -> updateUntil("litebans_bans", id, until);
|
case BAN -> updateUntil("litebans_bans", id, until);
|
||||||
case MUTE -> updateUntil("litebans_mutes", id, until);
|
case MUTE -> updateUntil("litebans_mutes", id, until);
|
||||||
case KICK -> throw new IllegalArgumentException("KICK has no until");
|
case KICK -> throw new IllegalArgumentException("KICK has no until");
|
||||||
case WARN -> updateUntil("litebans_warnings", id, until);
|
case WARN -> throw new IllegalArgumentException("WARN has no until");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.luckperms;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record PlayerWithGroup(String username, UUID uuid, String group) {
|
|
||||||
}
|
|
||||||
|
|
@ -15,24 +15,8 @@ public interface TeamMemberMapper {
|
||||||
SELECT players.username, players.uuid
|
SELECT players.username, players.uuid
|
||||||
FROM luckperms_user_permissions AS permissions
|
FROM luckperms_user_permissions AS permissions
|
||||||
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
|
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
|
||||||
WHERE permission = #{groupPermission} AND server = 'global'
|
WHERE permission = #{groupPermission}
|
||||||
AND world = 'global'
|
AND world = 'global'
|
||||||
""")
|
""")
|
||||||
List<Player> getTeamMembers(@Param("groupPermission") String groupPermission);
|
List<Player> getTeamMembers(@Param("groupPermission") String groupPermission);
|
||||||
|
|
||||||
@ConstructorArgs({
|
|
||||||
@Arg(column = "username", javaType = String.class),
|
|
||||||
@Arg(column = "uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class),
|
|
||||||
@Arg(column = "group", javaType = String.class)
|
|
||||||
})
|
|
||||||
@Select("""
|
|
||||||
SELECT players.username, players.uuid, permissions.permission AS 'group'
|
|
||||||
FROM luckperms_user_permissions AS permissions
|
|
||||||
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
|
|
||||||
WHERE permission IN (${groupPermissions})
|
|
||||||
AND server = 'global'
|
|
||||||
AND world = 'global'
|
|
||||||
""")
|
|
||||||
List<PlayerWithGroup> getTeamMembersOfGroupList(@Param("groupPermissions") String groupPermissions);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.proxyplaytime;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.type_handler.UUIDTypeHandler;
|
|
||||||
import org.apache.ibatis.annotations.Arg;
|
|
||||||
import org.apache.ibatis.annotations.ConstructorArgs;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface StaffPlaytimeMapper {
|
|
||||||
@ConstructorArgs({
|
|
||||||
@Arg(column = "uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class),
|
|
||||||
@Arg(column = "serverName", javaType = String.class),
|
|
||||||
@Arg(column = "sessionStart", javaType = long.class),
|
|
||||||
@Arg(column = "sessionEnd", javaType = long.class)
|
|
||||||
})
|
|
||||||
@Select("""
|
|
||||||
SELECT uuid,
|
|
||||||
server_name AS serverName,
|
|
||||||
session_start AS sessionStart,
|
|
||||||
session_end AS sessionEnd
|
|
||||||
FROM sessions
|
|
||||||
WHERE session_end > #{from}
|
|
||||||
AND session_start < #{to}
|
|
||||||
AND uuid IN (${staffUUIDs})
|
|
||||||
ORDER BY uuid, session_start
|
|
||||||
""")
|
|
||||||
List<StaffPt> getSessionsDuring(@Param("from") long from,
|
|
||||||
@Param("to") long to,
|
|
||||||
@Param("staffUUIDs") String staffUUIDs);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.proxyplaytime;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record StaffPt(UUID uuid, String serverName, long sessionStart, long sessionEnd) {
|
|
||||||
}
|
|
||||||
|
|
@ -21,7 +21,7 @@ public interface VotingPluginUsersMapper {
|
||||||
WeeklyTotal as totalVotesThisWeek,
|
WeeklyTotal as totalVotesThisWeek,
|
||||||
MonthTotal as totalVotesThisMonth,
|
MonthTotal as totalVotesThisMonth,
|
||||||
AllTimeTotal as totalVotesAllTime
|
AllTimeTotal as totalVotesAllTime
|
||||||
FROM votingplugin.VotingPlugin_Users
|
FROM votingplugin.votingplugin_users
|
||||||
WHERE uuid = #{uuid}
|
WHERE uuid = #{uuid}
|
||||||
""")
|
""")
|
||||||
Optional<VotingStatsRow> getStatsByUuid(@Param("uuid") UUID uuid);
|
Optional<VotingStatsRow> getStatsByUuid(@Param("uuid") UUID uuid);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.alttd.altitudeweb.database.web_db;
|
package com.alttd.altitudeweb.database.web_db;
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.*;
|
import org.apache.ibatis.annotations.*;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
@ -102,9 +103,9 @@ public interface PrivilegedUserMapper {
|
||||||
int removePermissionFromUser(@Param("userId") int userId, @Param("permission") String permission);
|
int removePermissionFromUser(@Param("userId") int userId, @Param("permission") String permission);
|
||||||
|
|
||||||
@Insert("""
|
@Insert("""
|
||||||
INSERT INTO privileged_users (uuid)
|
INSERT INTO privileged_users (uuid)
|
||||||
VALUES (#{user.uuid})
|
VALUES (#{uuid})
|
||||||
""")
|
""")
|
||||||
@SelectKey(statement = "SELECT LAST_INSERT_ID()", keyProperty = "user.id", before = false, resultType = int.class)
|
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||||
void createPrivilegedUser(@Param("user") PrivilegedUser user);
|
int createPrivilegedUser(UUID uuid);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.web_db.chat_session;
|
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@Builder
|
|
||||||
public class ChatSession {
|
|
||||||
|
|
||||||
private final UUID uuid;
|
|
||||||
private final Instant session_start;
|
|
||||||
private final Instant session_end;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.web_db.chat_session;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Insert;
|
|
||||||
|
|
||||||
public interface ChatSessionMapper {
|
|
||||||
|
|
||||||
@Insert("""
|
|
||||||
INSERT INTO chat_session (uuid, session_start, session_end)
|
|
||||||
VALUES (#{uuid}, #{session_start}, #{session_end})
|
|
||||||
""")
|
|
||||||
void storeSession(ChatSession chatSession);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.alttd.altitudeweb.database.web_db.forms;
|
package com.alttd.altitudeweb.database.web_db.forms;
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Insert;
|
import org.apache.ibatis.annotations.Insert;
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
import org.apache.ibatis.annotations.Update;
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
|
@ -34,11 +33,5 @@ public interface AppealMapper {
|
||||||
UPDATE appeals SET send_at = NOW()
|
UPDATE appeals SET send_at = NOW()
|
||||||
WHERE id = #{id}
|
WHERE id = #{id}
|
||||||
""")
|
""")
|
||||||
void markAppealAsSent(@Param("id") UUID id);
|
void markAppealAsSent(UUID id);
|
||||||
|
|
||||||
@Update("""
|
|
||||||
UPDATE appeals SET assigned_to = #{assignedTo}
|
|
||||||
WHERE id = #{id}
|
|
||||||
""")
|
|
||||||
void assignAppeal(@Param("id") UUID id, @Param("assignedTo") Long assignedTo);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.web_db.forms;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record DiscordAppeal(
|
|
||||||
UUID id,
|
|
||||||
UUID uuid,
|
|
||||||
Long discordId,
|
|
||||||
String discordUsername,
|
|
||||||
String reason,
|
|
||||||
Instant createdAt,
|
|
||||||
Instant sendAt,
|
|
||||||
String email,
|
|
||||||
Long assignedTo
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
package com.alttd.altitudeweb.database.web_db.forms;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Insert;
|
|
||||||
import org.apache.ibatis.annotations.Update;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface DiscordAppealMapper {
|
|
||||||
|
|
||||||
@Insert("""
|
|
||||||
INSERT INTO discord_appeals (uuid, discord_id, discord_username, reason, created_at, send_at, e_mail, assigned_to)
|
|
||||||
VALUES (#{uuid}, #{discordId}, #{discordUsername}, #{reason}, #{createdAt}, #{sendAt}, #{email}, #{assignedTo})
|
|
||||||
""")
|
|
||||||
void createDiscordAppeal(DiscordAppeal discordAppeal);
|
|
||||||
|
|
||||||
@Update("""
|
|
||||||
UPDATE discord_appeals SET send_at = NOW()
|
|
||||||
WHERE id = #{id}
|
|
||||||
""")
|
|
||||||
void markDiscordAppealAsSent(@Param("id") UUID id);
|
|
||||||
|
|
||||||
@Update("""
|
|
||||||
UPDATE discord_appeals SET assigned_to = #{assignedTo}
|
|
||||||
WHERE id = #{id}
|
|
||||||
""")
|
|
||||||
void assignDiscordAppeal(@Param("id") UUID id, @Param("assignedTo") Long assignedTo);
|
|
||||||
}
|
|
||||||
|
|
@ -37,10 +37,8 @@ public class Connection {
|
||||||
InitializeWebDb.init();
|
InitializeWebDb.init();
|
||||||
InitializeLiteBans.init();
|
InitializeLiteBans.init();
|
||||||
InitializeLuckPerms.init();
|
InitializeLuckPerms.init();
|
||||||
InitializeProxyPlaytime.init();
|
|
||||||
InitializeDiscord.init();
|
InitializeDiscord.init();
|
||||||
InitializeVotingPlugin.init();
|
InitializeVotingPlugin.init();
|
||||||
InitializeChat.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
|
|
@ -144,26 +142,6 @@ public class Connection {
|
||||||
}).start();
|
}).start();
|
||||||
}
|
}
|
||||||
|
|
||||||
public <T> T runQueryWithResult(QueryFunction<T> function) {
|
|
||||||
if (sqlSessionFactory == null) {
|
|
||||||
sqlSessionFactory = createSqlSessionFactory(settings, addMappers);
|
|
||||||
}
|
|
||||||
|
|
||||||
try (SqlSession session = sqlSessionFactory.openSession()) {
|
|
||||||
T result = function.apply(session);
|
|
||||||
session.commit();
|
|
||||||
return result;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to run query", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface QueryFunction<T> {
|
|
||||||
T apply(SqlSession session);
|
|
||||||
}
|
|
||||||
|
|
||||||
private SqlSessionFactory createSqlSessionFactory(DatabaseSettings settings, AddMappers addMappers) {
|
private SqlSessionFactory createSqlSessionFactory(DatabaseSettings settings, AddMappers addMappers) {
|
||||||
try {
|
try {
|
||||||
Configuration configuration = getConfiguration(settings);
|
Configuration configuration = getConfiguration(settings);
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package com.alttd.altitudeweb.setup;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
|
||||||
import com.alttd.altitudeweb.database.chat.ChatLogMapper;
|
|
||||||
import com.alttd.altitudeweb.database.chat.NicknameMapper;
|
|
||||||
import com.alttd.altitudeweb.database.chat.PartyMapper;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
public class InitializeChat {
|
|
||||||
|
|
||||||
protected static void init() {
|
|
||||||
log.info("Initializing Chat");
|
|
||||||
Connection.getConnection(Databases.CHAT, (configuration) -> {
|
|
||||||
configuration.addMapper(ChatLogMapper.class);
|
|
||||||
configuration.addMapper(NicknameMapper.class);
|
|
||||||
configuration.addMapper(PartyMapper.class);
|
|
||||||
}).join();
|
|
||||||
log.debug("Initialized Chat");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.alttd.altitudeweb.setup;
|
package com.alttd.altitudeweb.setup;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
import com.alttd.altitudeweb.database.Databases;
|
||||||
import com.alttd.altitudeweb.database.discord.AppealListMapper;
|
|
||||||
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
|
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
|
||||||
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
|
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
@ -13,7 +12,6 @@ public class InitializeDiscord {
|
||||||
log.info("Initializing Discord");
|
log.info("Initializing Discord");
|
||||||
Connection.getConnection(Databases.DISCORD, (configuration) -> {
|
Connection.getConnection(Databases.DISCORD, (configuration) -> {
|
||||||
configuration.addMapper(OutputChannelMapper.class);
|
configuration.addMapper(OutputChannelMapper.class);
|
||||||
configuration.addMapper(AppealListMapper.class);
|
|
||||||
}).join();
|
}).join();
|
||||||
log.debug("Initialized Discord");
|
log.debug("Initialized Discord");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
package com.alttd.altitudeweb.setup;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
|
||||||
import com.alttd.altitudeweb.database.proxyplaytime.StaffPlaytimeMapper;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
public class InitializeProxyPlaytime {
|
|
||||||
|
|
||||||
protected static void init() {
|
|
||||||
log.info("Initializing ProxyPlaytime");
|
|
||||||
Connection.getConnection(Databases.PROXY_PLAYTIME, (configuration) -> {
|
|
||||||
configuration.addMapper(StaffPlaytimeMapper.class);
|
|
||||||
}).join();
|
|
||||||
log.debug("Initialized ProxyPlaytime");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -4,9 +4,7 @@ import com.alttd.altitudeweb.database.Databases;
|
||||||
import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
|
import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
|
||||||
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
||||||
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
|
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
|
||||||
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSessionMapper;
|
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
|
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppealMapper;
|
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
|
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
|
||||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
@ -26,10 +24,8 @@ public class InitializeWebDb {
|
||||||
configuration.addMapper(KeyPairMapper.class);
|
configuration.addMapper(KeyPairMapper.class);
|
||||||
configuration.addMapper(PrivilegedUserMapper.class);
|
configuration.addMapper(PrivilegedUserMapper.class);
|
||||||
configuration.addMapper(AppealMapper.class);
|
configuration.addMapper(AppealMapper.class);
|
||||||
configuration.addMapper(DiscordAppealMapper.class);
|
|
||||||
configuration.addMapper(StaffApplicationMapper.class);
|
configuration.addMapper(StaffApplicationMapper.class);
|
||||||
configuration.addMapper(EmailVerificationMapper.class);
|
configuration.addMapper(EmailVerificationMapper.class);
|
||||||
configuration.addMapper(ChatSessionMapper.class);
|
|
||||||
}).join()
|
}).join()
|
||||||
.runQuery(sqlSession -> {
|
.runQuery(sqlSession -> {
|
||||||
createSettingsTable(sqlSession);
|
createSettingsTable(sqlSession);
|
||||||
|
|
@ -37,10 +33,8 @@ public class InitializeWebDb {
|
||||||
createPrivilegedUsersTable(sqlSession);
|
createPrivilegedUsersTable(sqlSession);
|
||||||
createPrivilegesTable(sqlSession);
|
createPrivilegesTable(sqlSession);
|
||||||
createAppealTable(sqlSession);
|
createAppealTable(sqlSession);
|
||||||
createdDiscordAppealTable(sqlSession);
|
|
||||||
createStaffApplicationsTable(sqlSession);
|
createStaffApplicationsTable(sqlSession);
|
||||||
createUserEmailsTable(sqlSession);
|
createUserEmailsTable(sqlSession);
|
||||||
createChatSessionTable(sqlSession);
|
|
||||||
});
|
});
|
||||||
log.debug("Initialized WebDb");
|
log.debug("Initialized WebDb");
|
||||||
}
|
}
|
||||||
|
|
@ -189,43 +183,4 @@ public class InitializeWebDb {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void createdDiscordAppealTable(@NotNull SqlSession sqlSession) {
|
|
||||||
String query = """
|
|
||||||
CREATE TABLE IF NOT EXISTS discord_appeals (
|
|
||||||
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
|
|
||||||
uuid UUID NOT NULL,
|
|
||||||
discord_id BIGINT UNSIGNED NOT NULL,
|
|
||||||
discord_username VARCHAR(32) NOT NULL,
|
|
||||||
reason TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
send_at TIMESTAMP NULL,
|
|
||||||
e_mail TEXT NOT NULL,
|
|
||||||
assigned_to BIGINT UNSIGNED NULL,
|
|
||||||
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
""";
|
|
||||||
try (Statement statement = sqlSession.getConnection().createStatement()) {
|
|
||||||
statement.execute(query);
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void createChatSessionTable(@NotNull SqlSession sqlSession) {
|
|
||||||
String query = """
|
|
||||||
CREATE TABLE IF NOT EXISTS chat_session (
|
|
||||||
id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
uuid UUID NOT NULL,
|
|
||||||
session_start TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
session_end TIMESTAMP NULL,
|
|
||||||
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
""";
|
|
||||||
try (Statement statement = sqlSession.getConnection().createStatement()) {
|
|
||||||
statement.execute(query);
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,12 @@ import lombok.extern.slf4j.Slf4j;
|
||||||
public class DiscordBot {
|
public class DiscordBot {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
DiscordBotInstance discordBotInstance = DiscordBotInstance.getInstance();
|
String discordToken = System.getProperty("DISCORD_TOKEN");
|
||||||
discordBotInstance.getJda();
|
if (discordToken == null) {
|
||||||
|
log.error("Discord token not found, put it in the DISCORD_TOKEN environment variable");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
DiscordBotInstance discordBotInstance = new DiscordBotInstance();
|
||||||
|
discordBotInstance.start(discordToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
package com.alttd.webinterface.appeals;
|
|
||||||
|
|
||||||
import com.alttd.webinterface.objects.MessageForEmbed;
|
|
||||||
import com.alttd.webinterface.send_message.DiscordSender;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import net.dv8tion.jda.api.components.actionrow.ActionRow;
|
|
||||||
import net.dv8tion.jda.api.components.buttons.Button;
|
|
||||||
import net.dv8tion.jda.api.entities.Message;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
public class AppealSender {
|
|
||||||
|
|
||||||
private static final AppealSender INSTANCE = new AppealSender();
|
|
||||||
|
|
||||||
public static AppealSender getInstance() {
|
|
||||||
return INSTANCE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void sendAppeal(List<Long> channelIds, MessageForEmbed messageForEmbed, long assignedTo) {
|
|
||||||
DiscordSender.getInstance()
|
|
||||||
.sendEmbedToChannels(channelIds, messageForEmbed)
|
|
||||||
.whenCompleteAsync((result, error) -> {
|
|
||||||
if (error != null) {
|
|
||||||
log.error("Failed sending embed to channels", error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
List<Message> list = result.stream()
|
|
||||||
.filter(Optional::isPresent)
|
|
||||||
.map(Optional::get)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
|
|
||||||
list.forEach(message -> {
|
|
||||||
message.createThreadChannel("Appeal")
|
|
||||||
.queue(channel -> {
|
|
||||||
if (assignedTo == 0L) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String assignedUserMessage = "<@" + assignedTo + "> you have a new appeal!";
|
|
||||||
channel.sendMessage(assignedUserMessage).queue();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
addButtons(list);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void addButtons(List<Message> messages) {
|
|
||||||
Button reminderAccepted = Button.primary("reminder_accepted", "Accepted");
|
|
||||||
Button reminderInProgress = Button.secondary("reminder_in_progress", "In Progress");
|
|
||||||
Button reminderDenied = Button.danger("reminder_denied", "Denied");
|
|
||||||
messages.forEach(message -> {
|
|
||||||
message.editMessageComponents(ActionRow.of(reminderAccepted, reminderInProgress, reminderDenied)).queue();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
package com.alttd.webinterface.appeals;
|
|
||||||
|
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
|
||||||
|
|
||||||
public class BanToBannedUser {
|
|
||||||
|
|
||||||
public static BannedUser map(Guild.Ban ban) {
|
|
||||||
return new BannedUser(ban.getUser().getIdLong(),
|
|
||||||
ban.getReason(),
|
|
||||||
ban.getUser().getEffectiveName(),
|
|
||||||
ban.getUser().getEffectiveAvatarUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
package com.alttd.webinterface.appeals;
|
|
||||||
|
|
||||||
public record BannedUser(long userId, String reason, String name, String avatarUrl) {
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
package com.alttd.webinterface.appeals;
|
|
||||||
|
|
||||||
import com.alttd.webinterface.bot.DiscordBotInstance;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import net.dv8tion.jda.api.entities.Guild;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
public class DiscordAppealDiscord {
|
|
||||||
private static final DiscordAppealDiscord INSTANCE = new DiscordAppealDiscord();
|
|
||||||
|
|
||||||
public static DiscordAppealDiscord getInstance() {
|
|
||||||
return INSTANCE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CompletableFuture<Optional<BannedUser>> getBannedUser(long discordId) {
|
|
||||||
Guild guildById = DiscordBotInstance.getInstance()
|
|
||||||
.getJda()
|
|
||||||
.getGuildById(141644560005595136L);
|
|
||||||
if (guildById == null) {
|
|
||||||
throw new IllegalStateException("Guild not found");
|
|
||||||
}
|
|
||||||
CompletableFuture<Optional<BannedUser>> completableFuture = new CompletableFuture<>();
|
|
||||||
log.info("Retrieving ban for user {}", discordId);
|
|
||||||
DiscordBotInstance.getInstance().getJda().retrieveUserById(discordId)
|
|
||||||
.queue(user -> {
|
|
||||||
log.info("Found user {}", user.getEffectiveName());
|
|
||||||
guildById.retrieveBan(user).queue(ban -> {
|
|
||||||
if (ban == null) {
|
|
||||||
completableFuture.complete(Optional.empty());
|
|
||||||
log.info("User {} is not banned", user.getEffectiveName());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.info("User {} is banned", user.getEffectiveName());
|
|
||||||
completableFuture.complete(Optional.of(BanToBannedUser.map(ban)));
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
return completableFuture;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,41 +6,14 @@ import net.dv8tion.jda.api.JDA;
|
||||||
import net.dv8tion.jda.api.JDABuilder;
|
import net.dv8tion.jda.api.JDABuilder;
|
||||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class DiscordBotInstance {
|
public class DiscordBotInstance {
|
||||||
|
|
||||||
private static final DiscordBotInstance INSTANCE = new DiscordBotInstance();
|
@Getter
|
||||||
|
|
||||||
public static DiscordBotInstance getInstance() {
|
|
||||||
return INSTANCE;
|
|
||||||
}
|
|
||||||
|
|
||||||
private DiscordBotInstance() {}
|
|
||||||
|
|
||||||
private JDA jda;
|
private JDA jda;
|
||||||
private volatile boolean ready = false;
|
private volatile boolean ready = false;
|
||||||
|
|
||||||
public JDA getJda() {
|
public synchronized void start(String token) {
|
||||||
if (jda == null) {
|
|
||||||
String discordToken = Optional.ofNullable(System.getenv("DISCORD_TOKEN"))
|
|
||||||
.orElse(System.getProperty("DISCORD_TOKEN"));
|
|
||||||
if (discordToken == null) {
|
|
||||||
log.error("Discord token not found, put it in the DISCORD_TOKEN environment variable");
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
start(discordToken);
|
|
||||||
try {
|
|
||||||
jda.awaitReady();
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return jda;
|
|
||||||
}
|
|
||||||
|
|
||||||
private synchronized void start(String token) {
|
|
||||||
if (jda != null) {
|
if (jda != null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
package com.alttd.webinterface.objects;
|
|
||||||
|
|
||||||
import com.alttd.webinterface.send_message.DiscordSender;
|
|
||||||
import net.dv8tion.jda.api.EmbedBuilder;
|
|
||||||
import net.dv8tion.jda.api.entities.MessageEmbed;
|
|
||||||
|
|
||||||
import java.awt.*;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record MessageForEmbed(String title, String description, List<DiscordSender.EmbedField> fields, Integer colorRgb,
|
|
||||||
Instant timestamp, String footer) {
|
|
||||||
|
|
||||||
public MessageEmbed toEmbed() {
|
|
||||||
EmbedBuilder eb = new EmbedBuilder();
|
|
||||||
if (title != null && !title.isBlank()) {
|
|
||||||
eb.setTitle(title);
|
|
||||||
}
|
|
||||||
if (description != null && !description.isBlank()) {
|
|
||||||
eb.setDescription(description);
|
|
||||||
}
|
|
||||||
if (colorRgb != null) {
|
|
||||||
eb.setColor(new Color(colorRgb));
|
|
||||||
} else {
|
|
||||||
eb.setColor(new Color(0xFF8C00)); // default orange
|
|
||||||
}
|
|
||||||
eb.setTimestamp(timestamp != null ? timestamp : Instant.now());
|
|
||||||
if (footer != null && !footer.isBlank()) {
|
|
||||||
eb.setFooter(footer);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fields != null) {
|
|
||||||
for (DiscordSender.EmbedField f : fields) {
|
|
||||||
if (f == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String name = f.getName() == null ? "" : f.getName();
|
|
||||||
String value = f.getValue() == null ? "" : f.getValue();
|
|
||||||
// JDA field value max is 1024; truncate to be safe
|
|
||||||
if (value.length() > 1024) {
|
|
||||||
value = value.substring(0, 1021) + "...";
|
|
||||||
}
|
|
||||||
eb.addField(new MessageEmbed.Field(name, value, f.isInline()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return eb.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +1,26 @@
|
||||||
package com.alttd.webinterface.send_message;
|
package com.alttd.webinterface.send_message;
|
||||||
|
|
||||||
import com.alttd.webinterface.bot.DiscordBotInstance;
|
import com.alttd.webinterface.bot.DiscordBotInstance;
|
||||||
import com.alttd.webinterface.objects.MessageForEmbed;
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.dv8tion.jda.api.entities.Message;
|
import net.dv8tion.jda.api.EmbedBuilder;
|
||||||
import net.dv8tion.jda.api.entities.MessageEmbed;
|
import net.dv8tion.jda.api.entities.MessageEmbed;
|
||||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.awt.*;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class DiscordSender {
|
public class DiscordSender {
|
||||||
|
|
||||||
private static final DiscordSender INSTANCE = new DiscordSender();
|
private static final DiscordSender INSTANCE = new DiscordSender();
|
||||||
|
|
||||||
private final DiscordBotInstance botInstance = DiscordBotInstance.getInstance();
|
private final DiscordBotInstance botInstance = new DiscordBotInstance();
|
||||||
|
|
||||||
private DiscordSender() {}
|
private DiscordSender() {}
|
||||||
|
|
||||||
|
|
@ -30,7 +28,19 @@ public class DiscordSender {
|
||||||
return INSTANCE;
|
return INSTANCE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ensureStarted() {
|
||||||
|
if (botInstance.getJda() != null) return;
|
||||||
|
String token = Optional.ofNullable(System.getenv("DISCORD_TOKEN"))
|
||||||
|
.orElse(System.getProperty("DISCORD_TOKEN"));
|
||||||
|
if (token == null || token.isBlank()) {
|
||||||
|
log.error("Discord token not found. Set DISCORD_TOKEN as an environment variable or system property.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
botInstance.start(token);
|
||||||
|
}
|
||||||
|
|
||||||
public void sendMessageToChannels(List<Long> channelIds, String message) {
|
public void sendMessageToChannels(List<Long> channelIds, String message) {
|
||||||
|
ensureStarted();
|
||||||
if (botInstance.getJda() == null) {
|
if (botInstance.getJda() == null) {
|
||||||
log.error("JDA not initialized; cannot send Discord message.");
|
log.error("JDA not initialized; cannot send Discord message.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -60,36 +70,12 @@ public class DiscordSender {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendEmbedWithThreadToChannels(List<Long> channelIds, MessageForEmbed messageForEmbed, String threadName) {
|
public void sendEmbedToChannels(List<Long> channelIds, String title, String description, List<EmbedField> fields,
|
||||||
sendEmbedToChannels(channelIds, messageForEmbed).whenCompleteAsync((result, error) -> {
|
Integer colorRgb, Instant timestamp, String footer) {
|
||||||
if (error != null) {
|
ensureStarted();
|
||||||
log.error("Failed sending embed to channels", error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
result.stream()
|
|
||||||
.filter(Optional::isPresent)
|
|
||||||
.map(Optional::get)
|
|
||||||
.forEach(message ->
|
|
||||||
message.createThreadChannel(threadName).queue());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public CompletableFuture<List<Optional<Message>>> sendEmbedToChannels(List<Long> channelIds, MessageForEmbed messageForEmbed) {
|
|
||||||
List<CompletableFuture<Optional<Message>>> futures = new ArrayList<>();
|
|
||||||
for (Long channelId : channelIds) {
|
|
||||||
futures.add(sendEmbedToChannel(channelId, messageForEmbed));
|
|
||||||
}
|
|
||||||
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
|
|
||||||
.thenApply(v ->
|
|
||||||
futures.stream()
|
|
||||||
.map(CompletableFuture::join)
|
|
||||||
.collect(Collectors.toList()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public CompletableFuture<Optional<Message>> sendEmbedToChannel(Long channelId, MessageForEmbed messageForEmbed) {
|
|
||||||
if (botInstance.getJda() == null) {
|
if (botInstance.getJda() == null) {
|
||||||
log.error("JDA not initialized; cannot send Discord embed.");
|
log.error("JDA not initialized; cannot send Discord embed.");
|
||||||
return CompletableFuture.completedFuture(Optional.empty());
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!botInstance.isReady()) {
|
if (!botInstance.isReady()) {
|
||||||
|
|
@ -97,31 +83,43 @@ public class DiscordSender {
|
||||||
}
|
}
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
return CompletableFuture.completedFuture(Optional.empty());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Error while waiting for JDA ready state", e);
|
log.warn("Error while waiting for JDA ready state", e);
|
||||||
return CompletableFuture.completedFuture(Optional.empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageEmbed embed = messageForEmbed.toEmbed();
|
EmbedBuilder eb = new EmbedBuilder();
|
||||||
|
if (title != null && !title.isBlank()) eb.setTitle(title);
|
||||||
|
if (description != null && !description.isBlank()) eb.setDescription(description);
|
||||||
|
if (colorRgb != null) eb.setColor(new Color(colorRgb)); else eb.setColor(new Color(0xFF8C00)); // default orange
|
||||||
|
eb.setTimestamp(timestamp != null ? timestamp : Instant.now());
|
||||||
|
if (footer != null && !footer.isBlank()) eb.setFooter(footer);
|
||||||
|
|
||||||
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, channelId);
|
if (fields != null) {
|
||||||
if (channel == null) {
|
for (EmbedField f : fields) {
|
||||||
log.warn("TextChannel with id {} not found when sending embed message", channelId);
|
if (f == null) continue;
|
||||||
return CompletableFuture.completedFuture(Optional.empty());
|
String name = f.getName() == null ? "" : f.getName();
|
||||||
|
String value = f.getValue() == null ? "" : f.getValue();
|
||||||
|
// JDA field value max is 1024; truncate to be safe
|
||||||
|
if (value.length() > 1024) value = value.substring(0, 1021) + "...";
|
||||||
|
eb.addField(new MessageEmbed.Field(name, value, f.isInline()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
CompletableFuture<Optional<Message>> completableFuture = new CompletableFuture<>();
|
|
||||||
channel.sendMessageEmbeds(embed).queue(
|
MessageEmbed embed = eb.build();
|
||||||
message -> {
|
|
||||||
completableFuture.complete(Optional.of(message));
|
channelIds.stream()
|
||||||
log.debug("Sent embed to channel {}", channelId);
|
.filter(Objects::nonNull)
|
||||||
},
|
.forEach(id -> {
|
||||||
error -> {
|
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id);
|
||||||
completableFuture.complete(Optional.empty());
|
if (channel == null) {
|
||||||
log.error("Failed sending embed to channel {}", channelId, error);
|
log.warn("TextChannel with id {} not found", id);
|
||||||
}
|
return;
|
||||||
);
|
}
|
||||||
return completableFuture;
|
channel.sendMessageEmbeds(embed).queue(
|
||||||
|
success -> log.debug("Sent embed to channel {}", id),
|
||||||
|
error -> log.error("Failed sending embed to channel {}", id, error)
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,6 @@
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "public",
|
"input": "public",
|
||||||
"output": "public"
|
"output": "public"
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "public",
|
|
||||||
"output": "assets"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,17 @@
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "20.2.2",
|
"@angular/cdk": "^20.1.3",
|
||||||
"@angular/common": "20.2.2",
|
"@angular/common": "^20.1.0",
|
||||||
"@angular/compiler": "20.2.2",
|
"@angular/compiler": "^20.1.0",
|
||||||
"@angular/core": "20.2.2",
|
"@angular/core": "^20.1.0",
|
||||||
"@angular/forms": "20.2.2",
|
"@angular/forms": "^20.1.0",
|
||||||
"@angular/material": "20.2.2",
|
"@angular/material": "^20.1.3",
|
||||||
"@angular/platform-browser": "20.2.2",
|
"@angular/platform-browser": "^20.1.0",
|
||||||
"@angular/platform-browser-dynamic": "20.2.2",
|
"@angular/platform-browser-dynamic": "^20.1.0",
|
||||||
"@angular/router": "20.2.2",
|
"@angular/router": "^20.1.0",
|
||||||
"@auth0/angular-jwt": "^5.2.0",
|
"@auth0/angular-jwt": "^5.2.0",
|
||||||
"@types/three": "^0.177.0",
|
"@types/three": "^0.177.0",
|
||||||
"event-source-polyfill": "^1.0.31",
|
|
||||||
"ngx-cookie-service": "^20.0.1",
|
"ngx-cookie-service": "^20.0.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"three": "^0.177.0",
|
"three": "^0.177.0",
|
||||||
|
|
@ -32,10 +31,9 @@
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "20.2.2",
|
"@angular/build": "^20.1.0",
|
||||||
"@angular/cli": "20.2.2",
|
"@angular/cli": "^20.1.0",
|
||||||
"@angular/compiler-cli": "20.2.2",
|
"@angular/compiler-cli": "^20.1.0",
|
||||||
"@types/event-source-polyfill": "^1.0.5",
|
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"jasmine-core": "~5.6.0",
|
"jasmine-core": "~5.6.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
|
|
@ -43,6 +41,6 @@
|
||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"/api": {
|
"/api": {
|
||||||
"target": "http://10.0.0.121:8080",
|
"target": "http://localhost:8080",
|
||||||
"secure": false,
|
"secure": false,
|
||||||
"changeOrigin": true
|
"changeOrigin": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
29
frontend/src/app/app.component.spec.ts
Normal file
29
frontend/src/app/app.component.spec.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import {TestBed} from '@angular/core/testing';
|
||||||
|
import {AppComponent} from './app.component';
|
||||||
|
|
||||||
|
describe('AppComponent', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AppComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should have the 'frontend' title`, () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app.title).toEqual('frontend');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render title', () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import {Component, inject, OnInit} from '@angular/core';
|
import {Component, OnInit} from '@angular/core';
|
||||||
import {Meta, Title} from '@angular/platform-browser';
|
import {Meta, Title} from '@angular/platform-browser';
|
||||||
import {ALTITUDE_VERSION} from '@custom-types/constant';
|
import {ALTITUDE_VERSION} from '@custom-types/constant';
|
||||||
import {RouterOutlet} from '@angular/router';
|
import {Router, RouterOutlet} from '@angular/router';
|
||||||
import {FooterComponent} from '@pages/footer/footer/footer.component';
|
import {FooterComponent} from '@pages/footer/footer/footer.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -27,8 +27,9 @@ export class AppComponent implements OnInit {
|
||||||
ALTITUDE_VERSION + ',altitude,alttd,play,join,find,friends,friendly,simple,private,whitelist,whitelisted,creative,' +
|
ALTITUDE_VERSION + ',altitude,alttd,play,join,find,friends,friendly,simple,private,whitelist,whitelisted,creative,' +
|
||||||
'worldedit'
|
'worldedit'
|
||||||
|
|
||||||
private readonly titleService = inject(Title);
|
constructor(private titleService: Title, private metaService: Meta, private router: Router) {
|
||||||
private readonly metaService = inject(Meta);
|
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.titleService.setTitle(this.title)
|
this.titleService.setTitle(this.title)
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,6 @@ import {Routes} from '@angular/router';
|
||||||
import {AuthGuard} from './guards/auth.guard';
|
import {AuthGuard} from './guards/auth.guard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
|
||||||
path: 'worlddl',
|
|
||||||
redirectTo: 'redirect/worlddl',
|
|
||||||
pathMatch: 'full'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'grove-dl',
|
|
||||||
redirectTo: 'redirect/grove-dl',
|
|
||||||
pathMatch: 'full'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'redirect/:type',
|
|
||||||
loadComponent: () => import('./shared-components/redirect/redirect.component').then(m => m.RedirectComponent),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'login/:code',
|
|
||||||
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent),
|
|
||||||
canActivate: [AuthGuard],
|
|
||||||
data: {
|
|
||||||
requiredAuthorizations: ['SCOPE_user']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent)
|
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent)
|
||||||
|
|
@ -36,14 +14,6 @@ export const routes: Routes = [
|
||||||
requiredAuthorizations: ['SCOPE_head_mod']
|
requiredAuthorizations: ['SCOPE_head_mod']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'staff-pt',
|
|
||||||
loadComponent: () => import('./pages/head-mod/staff-pt/staff-pt.component').then(m => m.StaffPtComponent),
|
|
||||||
canActivate: [AuthGuard],
|
|
||||||
data: {
|
|
||||||
requiredAuthorizations: ['SCOPE_head_mod']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'map',
|
path: 'map',
|
||||||
loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent)
|
loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent)
|
||||||
|
|
@ -140,38 +110,18 @@ export const routes: Routes = [
|
||||||
path: 'forms',
|
path: 'forms',
|
||||||
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
|
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'appeal/:code',
|
|
||||||
redirectTo: 'forms/appeal/:code',
|
|
||||||
pathMatch: 'full'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'appeal',
|
path: 'appeal',
|
||||||
redirectTo: 'forms/appeal',
|
redirectTo: 'forms/appeal',
|
||||||
pathMatch: 'full'
|
pathMatch: 'full'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'discord-appeal',
|
|
||||||
redirectTo: 'forms/discord-appeal',
|
|
||||||
pathMatch: 'full'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'forms/appeal/:code',
|
|
||||||
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
|
||||||
canActivate: [AuthGuard],
|
|
||||||
data: {requiredAuthorizations: ['SCOPE_user']}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'forms/appeal',
|
path: 'forms/appeal',
|
||||||
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
data: {requiredAuthorizations: ['SCOPE_user']}
|
data: {
|
||||||
},
|
requiredAuthorizations: ['SCOPE_user']
|
||||||
{
|
}
|
||||||
path: 'forms/discord-appeal',
|
|
||||||
loadComponent: () => import('./pages/forms/discord-appeal/discord-appeal.component').then(m => m.DiscordAppealComponent),
|
|
||||||
canActivate: [AuthGuard],
|
|
||||||
data: {requiredAuthorizations: ['SCOPE_user']}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'forms/sent',
|
path: 'forms/sent',
|
||||||
|
|
@ -198,16 +148,12 @@ export const routes: Routes = [
|
||||||
path: 'community',
|
path: 'community',
|
||||||
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)
|
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'chat',
|
|
||||||
loadComponent: () => import('./pages/altitude/chat/chat.component').then(m => m.ChatComponent)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'nicknames',
|
path: 'nicknames',
|
||||||
loadComponent: () => import('./pages/reference/nicknames/nicknames.component').then(m => m.NicknamesComponent)
|
loadComponent: () => import('./pages/reference/nicknames/nicknames.component').then(m => m.NicknamesComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'nickgenerator',
|
path: 'nickgenerator',
|
||||||
loadComponent: () => import('@pages/reference/nickgenerator/nick-generator.component').then(m => m.NickGeneratorComponent)
|
loadComponent: () => import('./pages/reference/nickgenerator/nickgenerator.component').then(m => m.NickgeneratorComponent)
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import {Injectable} from '@angular/core';
|
import {Injectable} from '@angular/core';
|
||||||
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
|
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
|
||||||
import {from, isObservable, map, Observable, of, switchMap} from 'rxjs';
|
import {map, Observable} from 'rxjs';
|
||||||
import {AuthService} from '@services/auth.service';
|
import {AuthService} from '@services/auth.service';
|
||||||
import {MatDialog} from '@angular/material/dialog';
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
import {LoginDialogComponent} from '@shared-components/login/login.component';
|
import {LoginDialogComponent} from '@shared-components/login/login.component';
|
||||||
import {catchError} from 'rxjs/operators';
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
|
|
@ -22,23 +21,6 @@ export class AuthGuard implements CanActivate {
|
||||||
route: ActivatedRouteSnapshot,
|
route: ActivatedRouteSnapshot,
|
||||||
state: RouterStateSnapshot
|
state: RouterStateSnapshot
|
||||||
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
||||||
const code = route.paramMap.get('code');
|
|
||||||
if (code) {
|
|
||||||
return this.authService.login(code).pipe(
|
|
||||||
switchMap(() => {
|
|
||||||
const result = this.canActivateInternal(route, state);
|
|
||||||
if (route.routeConfig?.path === 'login/:code') {
|
|
||||||
this.router.navigateByUrl('/', {replaceUrl: true}).then();
|
|
||||||
}
|
|
||||||
return isObservable(result) ? result : result instanceof Promise ? from(result) : of(result);
|
|
||||||
}),
|
|
||||||
catchError(() => of(this.router.createUrlTree(['/'])))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.canActivateInternal(route, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
private canActivateInternal(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
|
||||||
if (!this.authService.isAuthenticated$()) {
|
if (!this.authService.isAuthenticated$()) {
|
||||||
this.router.createUrlTree(['/']);
|
this.router.createUrlTree(['/']);
|
||||||
const dialogRef = this.dialog.open(LoginDialogComponent, {
|
const dialogRef = this.dialog.open(LoginDialogComponent, {
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<ng-container>
|
|
||||||
<app-header [current_page]="'chat'" height="200px" background_image="/public/img/backgrounds/staff.png"
|
|
||||||
[overlay_gradient]="0.5">
|
|
||||||
<div class="title" header-content>
|
|
||||||
<h1>Chat</h1>
|
|
||||||
</div>
|
|
||||||
</app-header>
|
|
||||||
|
|
||||||
@if (showFullscreenPrompt()) {
|
|
||||||
<div class="fullscreen-prompt-backdrop">
|
|
||||||
<div class="fullscreen-prompt">
|
|
||||||
<h2>Enter fullscreen?</h2>
|
|
||||||
<p>Would you like to view chat in fullscreen mode?</p>
|
|
||||||
|
|
||||||
<div class="fullscreen-prompt-actions">
|
|
||||||
<button type="button" class="secondary" (click)="skipFullscreen()">No thanks</button>
|
|
||||||
<button type="button" (click)="enterFullscreen()">Go fullscreen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<app-full-size [hideFooter]="true">
|
|
||||||
<section class="darkmodeSection full-height">
|
|
||||||
<div class="page-layout">
|
|
||||||
<div class="channel-list">
|
|
||||||
<app-channel-list [channels]="channels()" [selectedChannel]="selectedChannel()"
|
|
||||||
(selectedChannelChange)="onSelectedChannelChange($event)">
|
|
||||||
|
|
||||||
</app-channel-list>
|
|
||||||
</div>
|
|
||||||
<div class="chat">
|
|
||||||
<div class="chat-info">
|
|
||||||
<app-chat-info [selectedChannel]="selectedChannel()"></app-chat-info>
|
|
||||||
</div>
|
|
||||||
<div class="chat-box">
|
|
||||||
<app-chat-box [messages]="messages()"></app-chat-box>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</app-full-size>
|
|
||||||
</main>
|
|
||||||
</ng-container>
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
.fullscreen-prompt-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 10000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
background: rgb(0 0 0 / 70%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullscreen-prompt {
|
|
||||||
width: min(420px, 100%);
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: #1f1f1f;
|
|
||||||
color: #ffffff;
|
|
||||||
box-shadow: 0 20px 60px rgb(0 0 0 / 45%);
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0 0 22px;
|
|
||||||
color: rgb(255 255 255 / 75%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fullscreen-prompt-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
background: #4f8cff;
|
|
||||||
color: #ffffff;
|
|
||||||
font-weight: 700;
|
|
||||||
|
|
||||||
&.secondary {
|
|
||||||
background: rgb(255 255 255 / 14%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-layout {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
.channel-list {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: max-content;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
.chat-info {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 5px 5px 2px 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-box {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0 5px 0 5px;
|
|
||||||
min-height: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
import {AfterViewInit, Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
|
|
||||||
import {Subscription} from 'rxjs';
|
|
||||||
import {ChatService} from '@pages/altitude/chat/service/chat.service';
|
|
||||||
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
|
|
||||||
import {HeaderComponent} from '@header/header.component';
|
|
||||||
import {MiniMessageInteractionService} from '@pages/altitude/chat/mini-message/mini-message-interaction.service';
|
|
||||||
import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
|
|
||||||
import {ChatBoxComponent} from '@pages/altitude/chat/components/chat/chat-box.component';
|
|
||||||
import {ChannelListComponent} from '@pages/altitude/chat/components/channel-list/channel-list.component';
|
|
||||||
import {ChatInfoComponent} from '@pages/altitude/chat/components/chat-info/chat-info.component';
|
|
||||||
import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-chat',
|
|
||||||
imports: [
|
|
||||||
HeaderComponent,
|
|
||||||
FullSizeComponent,
|
|
||||||
ChatBoxComponent,
|
|
||||||
ChannelListComponent,
|
|
||||||
ChatInfoComponent
|
|
||||||
],
|
|
||||||
templateUrl: './chat.component.html',
|
|
||||||
styleUrl: './chat.component.scss',
|
|
||||||
providers: [MiniMessageInteractionService]
|
|
||||||
})
|
|
||||||
export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
|
|
||||||
private sub?: Subscription;
|
|
||||||
private readonly chatService: ChatService = inject(ChatService)
|
|
||||||
protected readonly messages = this.chatService.messages;
|
|
||||||
protected readonly channels = this.chatService.channels;
|
|
||||||
protected readonly selectedChannel = this.chatService.selectedChannel;
|
|
||||||
protected readonly showFullscreenPrompt = signal(false);
|
|
||||||
protected readonly isMobile = signal(false);
|
|
||||||
|
|
||||||
constructor(private interaction: MiniMessageInteractionService, private breakpointObserver: BreakpointObserver) {
|
|
||||||
interaction.clicks$.subscribe(({action, value}) => {
|
|
||||||
if (action === 'run_command') {
|
|
||||||
alert(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.breakpointObserver
|
|
||||||
.observe([Breakpoints.Handset])
|
|
||||||
.subscribe(result => {
|
|
||||||
this.isMobile.set(result.matches);
|
|
||||||
this.showFullscreenPrompt.set(this.isMobile());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onSelectedChannelChange(channel: ChatChannel) {
|
|
||||||
this.chatService.selectChannel(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected enterFullscreen(): void {
|
|
||||||
this.showFullscreenPrompt.set(false);
|
|
||||||
|
|
||||||
if (!document.fullscreenElement) {
|
|
||||||
document.documentElement.requestFullscreen().catch(() => {
|
|
||||||
// Ignore fullscreen request failures.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected skipFullscreen(): void {
|
|
||||||
this.showFullscreenPrompt.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.chatService.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
document.documentElement.requestFullscreen().catch(() => {
|
|
||||||
console.error('Failed to enter fullscreen');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.sub?.unsubscribe();
|
|
||||||
this.chatService.disconnect();
|
|
||||||
|
|
||||||
if (document.fullscreenElement) {
|
|
||||||
document.exitFullscreen().catch(() => {
|
|
||||||
console.error('Failed to exit fullscreen');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<div class="channel-list">
|
|
||||||
<p>Channels</p>
|
|
||||||
<div class="scroll">
|
|
||||||
@for (group of groupedChannels(); track group.type) {
|
|
||||||
<p class="channel-group-title" (click)="toggleGroup(group.type)">
|
|
||||||
{{ group.type }} {{ collapsedGroups().has(group.type) ? '▼' : '▲' }}
|
|
||||||
</p>
|
|
||||||
@if (!collapsedGroups().has(group.type)) {
|
|
||||||
@for (channel of group.channels; track channel.name) {
|
|
||||||
<div class="channel indented"
|
|
||||||
[class.selected]="selectedChannel()?.name === channel.name && selectedChannel()?.type === channel.type"
|
|
||||||
(click)="selectedChannelChange.emit(channel)">
|
|
||||||
<span class="channel-name">
|
|
||||||
@for (segment of getDisplayName(channel); track $index) {
|
|
||||||
<span [style.color]="segment.color">{{ segment.text }}</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
@if (channel.unreadMessages > 0) {
|
|
||||||
<div class="notification">
|
|
||||||
<svg-circle></svg-circle>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
.channel-list {
|
|
||||||
height: 100%;
|
|
||||||
background-color: #172133;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
.channel {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-bottom: 1px solid #222;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: #202c42;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.selected {
|
|
||||||
background-color: #202c42;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-name {
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification {
|
|
||||||
position: absolute;
|
|
||||||
right: 15px;
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel.indented {
|
|
||||||
padding-left: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-group-title {
|
|
||||||
padding: 10px 20px;
|
|
||||||
color: #888;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-sizing: border-box;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import {Component, computed, effect, inject, input, output, signal} from '@angular/core';
|
|
||||||
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
|
|
||||||
import {CircleSvgComponent} from '@pages/altitude/chat/svg/circle.svg';
|
|
||||||
import {NameFormatService} from '@pages/altitude/chat/service/name-format.service';
|
|
||||||
import {ColoredNameSegment} from '@pages/altitude/chat/objects/colored-name-segment.object';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-channel-list',
|
|
||||||
imports: [
|
|
||||||
CircleSvgComponent
|
|
||||||
],
|
|
||||||
templateUrl: './channel-list.component.html',
|
|
||||||
styleUrl: './channel-list.component.scss'
|
|
||||||
})
|
|
||||||
export class ChannelListComponent {
|
|
||||||
protected readonly nameFormatService: NameFormatService = inject(NameFormatService);
|
|
||||||
public readonly channels = input.required<ChatChannel[]>();
|
|
||||||
public readonly selectedChannel = input.required<ChatChannel | null>();
|
|
||||||
public readonly selectedChannelChange = output<ChatChannel>();
|
|
||||||
|
|
||||||
protected readonly collapsedGroups = signal<Set<string>>(new Set());
|
|
||||||
|
|
||||||
protected toggleGroup(type: string) {
|
|
||||||
const next = new Set(this.collapsedGroups());
|
|
||||||
if (next.has(type)) {
|
|
||||||
next.delete(type);
|
|
||||||
} else {
|
|
||||||
next.add(type);
|
|
||||||
}
|
|
||||||
this.collapsedGroups.set(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected readonly groupedChannels = computed(() => {
|
|
||||||
const channels = this.channels();
|
|
||||||
const groups: { type: ChatChannel['type'], channels: ChatChannel[] }[] = [];
|
|
||||||
const types: ChatChannel['type'][] = ['SERVER', 'DM', 'PARTY', 'GAC', 'SPY'];
|
|
||||||
|
|
||||||
for (const type of types) {
|
|
||||||
const typeChannels = channels.filter(c => c.type === type);
|
|
||||||
if (typeChannels.length > 0) {
|
|
||||||
groups.push({type, channels: typeChannels});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const chatChannels = this.channels();
|
|
||||||
if (this.selectedChannel() === null && chatChannels.length > 0) {
|
|
||||||
this.selectedChannelChange.emit(chatChannels[0]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getDisplayName(chatChannel: ChatChannel): ColoredNameSegment[] {
|
|
||||||
return this.nameFormatService.getDisplayName(chatChannel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
@if (selectedChannel(); as chatChannel) {
|
|
||||||
<div class="full-width"
|
|
||||||
[style.background-color]="getServerColor(chatChannel.name)">
|
|
||||||
<span class="channel" [style.color]="getServerTextColor(chatChannel.name)">
|
|
||||||
@for (segment of getDisplayName(chatChannel); track $index) {
|
|
||||||
<span [style.color]="segment.color">{{ segment.text }}</span>
|
|
||||||
}
|
|
||||||
<span class="message-count">({{ chatChannel.totalMessages }})</span>
|
|
||||||
</span>
|
|
||||||
<div class="notification-icon">
|
|
||||||
@if (notificationEnabled()) {
|
|
||||||
<svg-notification-enabled (click)="disableNotifications()"/>
|
|
||||||
} @else {
|
|
||||||
<svg-notification-disabled (click)="enableNotifications()"/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
.full-width {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding-top: 15px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel {
|
|
||||||
font-size: 20px;
|
|
||||||
font-family: minecraft-text, 'opensans-bold', sans-serif;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
font-weight: bold;
|
|
||||||
padding-left: 10px;
|
|
||||||
|
|
||||||
.message-count {
|
|
||||||
padding-left: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import {Component, computed, inject, input} from '@angular/core';
|
|
||||||
import {getServerColor, getServerTextColor} from '@pages/altitude/chat/util/server-color';
|
|
||||||
import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
|
|
||||||
import {NotificationEnabledSvgComponent} from '@pages/altitude/chat/svg/notification-enabled.svg';
|
|
||||||
import {NotificationDisabledSvgComponent} from '@pages/altitude/chat/svg/notification-disabled.svg';
|
|
||||||
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
|
|
||||||
import {ColoredNameSegment} from '@pages/altitude/chat/objects/colored-name-segment.object';
|
|
||||||
import {NameFormatService} from '@pages/altitude/chat/service/name-format.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-chat-info',
|
|
||||||
imports: [
|
|
||||||
NotificationEnabledSvgComponent,
|
|
||||||
NotificationDisabledSvgComponent
|
|
||||||
],
|
|
||||||
templateUrl: './chat-info.component.html',
|
|
||||||
styleUrl: './chat-info.component.scss'
|
|
||||||
})
|
|
||||||
export class ChatInfoComponent {
|
|
||||||
private readonly notificationService: NotificationService = inject(NotificationService);
|
|
||||||
private readonly nameFormatService: NameFormatService = inject(NameFormatService);
|
|
||||||
|
|
||||||
public readonly selectedChannel = input.required<ChatChannel | null>();
|
|
||||||
protected readonly channelKey = computed(() => {
|
|
||||||
const channel = this.selectedChannel();
|
|
||||||
return channel ? `${channel.type}:${channel.name}` : '';
|
|
||||||
});
|
|
||||||
protected readonly notificationEnabled = computed(() => this.notificationService.isNotificationEnabled(this.channelKey()));
|
|
||||||
protected readonly getServerColor = getServerColor;
|
|
||||||
protected readonly getServerTextColor = getServerTextColor;
|
|
||||||
|
|
||||||
public enableNotifications() {
|
|
||||||
this.notificationService.enableNotifications(this.channelKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
public disableNotifications() {
|
|
||||||
this.notificationService.disableNotifications(this.channelKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getDisplayName(chatChannel: ChatChannel): ColoredNameSegment[] {
|
|
||||||
return this.nameFormatService.getDisplayName(chatChannel);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<div class="message-container">
|
|
||||||
@for (message of messages(); track message.timestamp) {
|
|
||||||
<p class="message">
|
|
||||||
<span class="date-time" [class.blocked]="!message.notBlocked">
|
|
||||||
[{{ message.timestamp | date: 'mediumTime' }}]
|
|
||||||
</span>
|
|
||||||
@defer (on viewport; prefetch on idle) {
|
|
||||||
<mini-message [node]="message.miniMessageNode"></mini-message>
|
|
||||||
} @placeholder {
|
|
||||||
<span>…</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (canChat()) {
|
|
||||||
<div class="chat-input-container">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="chatText"
|
|
||||||
(keyup.enter)="sendMessage()"
|
|
||||||
placeholder="Type a message..."
|
|
||||||
/>
|
|
||||||
<button (click)="sendMessage()">Send</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
:host {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-container {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-sizing: border-box;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-container::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-container::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-container::-webkit-scrollbar-thumb {
|
|
||||||
background-color: rgba(255, 255, 255, 0.35);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-container::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blocked {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-time {
|
|
||||||
color: gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-container {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
|
|
||||||
input {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
color: white;
|
|
||||||
outline: none;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: #4f8cff;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: #4f8cff;
|
|
||||||
color: #ffffff;
|
|
||||||
font-weight: 700;
|
|
||||||
transition: background 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #3b7dff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: #2a6ae0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import {Component, computed, inject, input} from '@angular/core';
|
|
||||||
import {DatePipe} from '@angular/common';
|
|
||||||
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
|
|
||||||
import {MiniMessageComponent} from '@pages/altitude/chat/components/mini-message/mini-message.component';
|
|
||||||
import {AuthService} from '@services/auth.service';
|
|
||||||
import {ChatService} from '@pages/altitude/chat/service/chat.service';
|
|
||||||
import {ServerMessageService} from '@api';
|
|
||||||
import {FormsModule} from '@angular/forms';
|
|
||||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-chat-box',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
MiniMessageComponent,
|
|
||||||
DatePipe,
|
|
||||||
FormsModule
|
|
||||||
],
|
|
||||||
templateUrl: './chat-box.component.html',
|
|
||||||
styleUrl: './chat-box.component.scss'
|
|
||||||
})
|
|
||||||
export class ChatBoxComponent {
|
|
||||||
private readonly authService = inject(AuthService);
|
|
||||||
private readonly chatService = inject(ChatService);
|
|
||||||
private readonly serverMessageService = inject(ServerMessageService);
|
|
||||||
private readonly matSnackBar = inject(MatSnackBar);
|
|
||||||
|
|
||||||
readonly messages = input.required<ChatMessage[]>();
|
|
||||||
chatText = '';
|
|
||||||
|
|
||||||
readonly canChat = computed(() => {
|
|
||||||
const hasPrivilege = this.authService.hasAccess(['SCOPE_head_mod']);
|
|
||||||
const isServerChannel = this.chatService.selectedChannel()?.type === 'SERVER';
|
|
||||||
if (!hasPrivilege || !isServerChannel) {
|
|
||||||
console.log(`User has privilege ${hasPrivilege}, user in server channel ${isServerChannel}`);
|
|
||||||
}
|
|
||||||
return hasPrivilege && isServerChannel;
|
|
||||||
});
|
|
||||||
|
|
||||||
sendMessage() {
|
|
||||||
const message = this.chatText.trim();
|
|
||||||
if (!message) return;
|
|
||||||
|
|
||||||
const selectedChannel = this.chatService.selectedChannel();
|
|
||||||
const uuid = this.authService.getUuid();
|
|
||||||
|
|
||||||
if (selectedChannel?.type === 'SERVER' && uuid) {
|
|
||||||
this.serverMessageService.sendServerMessage(selectedChannel.name, {
|
|
||||||
uuid,
|
|
||||||
message
|
|
||||||
}).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.chatText = '';
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
this.matSnackBar.open('Failed to send message', 'Dismiss', {
|
|
||||||
duration: 3000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
@if (node) {
|
|
||||||
@if (node.isUrlLink) {
|
|
||||||
<a
|
|
||||||
[style]="node.ngStyle"
|
|
||||||
[mmObfuscated]="node.style.obfuscated"
|
|
||||||
[attr.title]="node.hoverTitle"
|
|
||||||
[attr.href]="node.clickEvent?.value"
|
|
||||||
[attr.data-insertion]="node.insertion || null"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="mm-node mm-link"
|
|
||||||
>{{ node.text }}</a>
|
|
||||||
} @else {
|
|
||||||
<span
|
|
||||||
[style]="node.ngStyle"
|
|
||||||
[mmObfuscated]="node.style.obfuscated"
|
|
||||||
[attr.title]="node.hoverTitle"
|
|
||||||
[attr.data-insertion]="node.insertion || null"
|
|
||||||
[class.mm-clickable]="node.hasNonUrlClick"
|
|
||||||
class="mm-node"
|
|
||||||
(click)="onClick()"
|
|
||||||
>{{ node.text }}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
@for (child of node.extra; track child) {
|
|
||||||
<mini-message [node]="child"></mini-message>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
.mm-node {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mm-clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mm-link {
|
|
||||||
text-decoration: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import {CommonModule} from '@angular/common';
|
|
||||||
import {Component, Input, Optional} from '@angular/core';
|
|
||||||
import {ProcessedMiniMessageNode} from '../../mini-message/mini-message.types';
|
|
||||||
import {ObfuscatedDirective} from '../../mini-message/obfuscated.directive';
|
|
||||||
import {MiniMessageInteractionService} from '../../mini-message/mini-message-interaction.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a Paper/Adventure component-JSON tree as styled HTML.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* providers: [MiniMessageInteractionService] // once, at the root that hosts <mini-message>
|
|
||||||
* <mini-message [node]="myProcessedComponentJson"></mini-message>
|
|
||||||
*
|
|
||||||
* Then subscribe to click events (run_command / suggest_command / copy_to_clipboard /
|
|
||||||
* change_page) via the injected MiniMessageInteractionService.clicks$; open_url is handled
|
|
||||||
* natively as a real link so no subscription is needed for that case.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mini-message',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, ObfuscatedDirective],
|
|
||||||
templateUrl: './mini-message.component.html',
|
|
||||||
styleUrl: './mini-message.component.scss',
|
|
||||||
})
|
|
||||||
export class MiniMessageComponent {
|
|
||||||
@Input() node!: ProcessedMiniMessageNode;
|
|
||||||
|
|
||||||
constructor(@Optional() private readonly interaction: MiniMessageInteractionService | null) {
|
|
||||||
}
|
|
||||||
|
|
||||||
onClick(): void {
|
|
||||||
const click = this.node.clickEvent;
|
|
||||||
if (!click) return;
|
|
||||||
|
|
||||||
if (click.action === 'copy_to_clipboard') {
|
|
||||||
navigator.clipboard?.writeText(click.value).then();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.interaction?.emitClick({action: click.action, value: click.value});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import { MiniMessageComponent, ResolvedStyle } from './mini-message.types';
|
|
||||||
import { hoverEventToTitle, mergeStyle, ownText, styleToNgStyle } from './mini-message.util';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escapes text for safe insertion into HTML. Always used on raw text content —
|
|
||||||
* never skip this when building the string yourself.
|
|
||||||
*/
|
|
||||||
function escapeHtml(input: string): string {
|
|
||||||
return input
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function ngStyleToCss(style: Record<string, string>): string {
|
|
||||||
return Object.entries(style)
|
|
||||||
.map(([key, value]) => `${key}:${value}`)
|
|
||||||
.join(';');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a component-JSON tree to an HTML string, e.g. for binding via
|
|
||||||
* [innerHTML]="miniMessageToHtml(json) | ..." with Angular's DomSanitizer, or for use
|
|
||||||
* outside Angular entirely. Prefer the <mini-message> component when you need click/hover
|
|
||||||
* interactivity or the obfuscated animation — this string form is static (obfuscated text
|
|
||||||
* renders as a fixed placeholder rather than an animated one).
|
|
||||||
*/
|
|
||||||
export function miniMessageToHtml(
|
|
||||||
node: MiniMessageComponent,
|
|
||||||
parentStyle?: ResolvedStyle
|
|
||||||
): string {
|
|
||||||
const style = mergeStyle(parentStyle, node);
|
|
||||||
const css = ngStyleToCss(styleToNgStyle(style));
|
|
||||||
const text = escapeHtml(ownText(node));
|
|
||||||
const title = hoverEventToTitle(node.hoverEvent);
|
|
||||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
|
|
||||||
const insertionAttr = node.insertion ? ` data-insertion="${escapeHtml(node.insertion)}"` : '';
|
|
||||||
|
|
||||||
let ownHtml: string;
|
|
||||||
if (node.clickEvent?.action === 'open_url') {
|
|
||||||
ownHtml = `<a href="${escapeHtml(
|
|
||||||
node.clickEvent.value
|
|
||||||
)}" target="_blank" rel="noopener noreferrer" class="mm-node mm-link" style="${css}"${titleAttr}${insertionAttr}>${text}</a>`;
|
|
||||||
} else {
|
|
||||||
const clickAttrs = node.clickEvent
|
|
||||||
? ` class="mm-node mm-clickable" data-click-action="${node.clickEvent.action}" data-click-value="${escapeHtml(
|
|
||||||
node.clickEvent.value
|
|
||||||
)}"`
|
|
||||||
: ' class="mm-node"';
|
|
||||||
ownHtml = `<span${clickAttrs} style="${css}"${titleAttr}${insertionAttr}>${text}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const childrenHtml = (node.extra ?? []).map((child) => miniMessageToHtml(child, style)).join('');
|
|
||||||
return ownHtml + childrenHtml;
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { Subject } from 'rxjs';
|
|
||||||
import { MiniMessageClickPayload } from './mini-message.types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Because <mini-message> renders itself recursively for `extra` children, a plain
|
|
||||||
* @Output() would need to be manually re-emitted at every level. Instead, every
|
|
||||||
* instance injects this single service (provided once at the root usage) and pushes
|
|
||||||
* clicks through it. Subscribe to `clicks$` wherever you render the top-level component.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class MiniMessageInteractionService {
|
|
||||||
private readonly clickSubject = new Subject<MiniMessageClickPayload>();
|
|
||||||
readonly clicks$ = this.clickSubject.asObservable();
|
|
||||||
|
|
||||||
emitClick(payload: MiniMessageClickPayload): void {
|
|
||||||
this.clickSubject.next(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { Pipe, PipeTransform } from '@angular/core';
|
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
|
||||||
import { MiniMessageComponent } from './mini-message.types';
|
|
||||||
import { miniMessageToHtml } from './mini-message-html.util';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <div [innerHTML]="componentJson | miniMessage"></div>
|
|
||||||
*
|
|
||||||
* Simpler than <mini-message> but static: no obfuscation animation, and clicks on
|
|
||||||
* non-URL actions (run_command, etc.) need to be wired up yourself via a (click) handler
|
|
||||||
* on the container using event delegation, reading data-click-action / data-click-value
|
|
||||||
* off event.target.
|
|
||||||
*/
|
|
||||||
@Pipe({
|
|
||||||
name: 'miniMessage',
|
|
||||||
standalone: true,
|
|
||||||
})
|
|
||||||
export class MiniMessagePipe implements PipeTransform {
|
|
||||||
constructor(private readonly sanitizer: DomSanitizer) {}
|
|
||||||
|
|
||||||
transform(node: MiniMessageComponent | null | undefined): SafeHtml {
|
|
||||||
if (!node) return '';
|
|
||||||
return this.sanitizer.bypassSecurityTrustHtml(miniMessageToHtml(node));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
/**
|
|
||||||
* Types for Paper/Adventure "Component" JSON — this is the format you get when a
|
|
||||||
* MiniMessage string is parsed on the server and serialized with GsonComponentSerializer.
|
|
||||||
* By the time it reaches your frontend, tags like <gradient>, <rainbow>, <color>, etc.
|
|
||||||
* have already been resolved into a tree of styled text runs (this is why you don't see
|
|
||||||
* "<red>" anywhere in the JSON — you see {"color":"red", ...} instead).
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type ClickAction =
|
|
||||||
| 'open_url'
|
|
||||||
| 'open_file'
|
|
||||||
| 'run_command'
|
|
||||||
| 'suggest_command'
|
|
||||||
| 'change_page'
|
|
||||||
| 'copy_to_clipboard';
|
|
||||||
|
|
||||||
export interface MiniMessageClickEvent {
|
|
||||||
action: ClickAction;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HoverAction = 'show_text' | 'show_item' | 'show_entity';
|
|
||||||
|
|
||||||
export interface MiniMessageHoverEvent {
|
|
||||||
action: HoverAction;
|
|
||||||
/** Modern (1.16+) format */
|
|
||||||
value?: MiniMessageComponent | string;
|
|
||||||
/** Legacy format some plugins/serializers still emit for show_text */
|
|
||||||
contents?: MiniMessageComponent | string | Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single node in the component tree. Every field is optional because children only need
|
|
||||||
* to specify what they *override* — everything else is inherited from the parent (color,
|
|
||||||
* bold, italic, underlined, strikethrough, obfuscated, font, clickEvent, hoverEvent, insertion).
|
|
||||||
*/
|
|
||||||
export interface MiniMessageComponent {
|
|
||||||
/** Plain text component */
|
|
||||||
text?: string;
|
|
||||||
|
|
||||||
/** Translatable component (e.g. vanilla "chat.type.text") */
|
|
||||||
translate?: string;
|
|
||||||
/** Substitution arguments for `translate`, in order */
|
|
||||||
with?: MiniMessageComponent[];
|
|
||||||
/** Fallback text if no translation is registered for `translate` */
|
|
||||||
fallback?: string;
|
|
||||||
|
|
||||||
/** Keybind component, e.g. "key.jump" */
|
|
||||||
keybind?: string;
|
|
||||||
|
|
||||||
/** Score component (scoreboard value) */
|
|
||||||
score?: { name: string; objective: string; value?: string };
|
|
||||||
|
|
||||||
/** Selector component, e.g. "@a" */
|
|
||||||
selector?: string;
|
|
||||||
|
|
||||||
/** Named color ("red", "aqua", ...), hex ("#RRGGBB"), or "reset" */
|
|
||||||
color?: string;
|
|
||||||
bold?: boolean;
|
|
||||||
italic?: boolean;
|
|
||||||
underlined?: boolean;
|
|
||||||
strikethrough?: boolean;
|
|
||||||
obfuscated?: boolean;
|
|
||||||
font?: string;
|
|
||||||
|
|
||||||
/** Text inserted into chat input on shift-click (Minecraft-client-only concept) */
|
|
||||||
insertion?: string;
|
|
||||||
|
|
||||||
clickEvent?: MiniMessageClickEvent;
|
|
||||||
hoverEvent?: MiniMessageHoverEvent;
|
|
||||||
|
|
||||||
/** Child components, styled relative to this node */
|
|
||||||
extra?: MiniMessageComponent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fully resolved node, pre-computed for rendering */
|
|
||||||
export interface ProcessedMiniMessageNode {
|
|
||||||
text: string;
|
|
||||||
style: ResolvedStyle;
|
|
||||||
ngStyle: Record<string, string>;
|
|
||||||
hoverTitle: string | null;
|
|
||||||
isUrlLink: boolean;
|
|
||||||
hasNonUrlClick: boolean;
|
|
||||||
clickEvent?: MiniMessageClickEvent;
|
|
||||||
insertion?: string;
|
|
||||||
extra: ProcessedMiniMessageNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fully resolved style at a given node, after inheriting from all ancestors */
|
|
||||||
export interface ResolvedStyle {
|
|
||||||
color?: string;
|
|
||||||
bold: boolean;
|
|
||||||
italic: boolean;
|
|
||||||
underlined: boolean;
|
|
||||||
strikethrough: boolean;
|
|
||||||
obfuscated: boolean;
|
|
||||||
font?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MiniMessageClickPayload {
|
|
||||||
action: ClickAction;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
import {
|
|
||||||
MiniMessageComponent,
|
|
||||||
MiniMessageHoverEvent,
|
|
||||||
ProcessedMiniMessageNode,
|
|
||||||
ResolvedStyle
|
|
||||||
} from './mini-message.types';
|
|
||||||
|
|
||||||
/** The 16 legacy Minecraft color names, as used by NamedTextColor / MiniMessage */
|
|
||||||
const NAMED_COLORS: Record<string, string> = {
|
|
||||||
black: '#000000',
|
|
||||||
dark_blue: '#0000AA',
|
|
||||||
dark_green: '#00AA00',
|
|
||||||
dark_aqua: '#00AAAA',
|
|
||||||
dark_red: '#AA0000',
|
|
||||||
dark_purple: '#AA00AA',
|
|
||||||
gold: '#FFAA00',
|
|
||||||
gray: '#AAAAAA',
|
|
||||||
dark_gray: '#555555',
|
|
||||||
blue: '#5555FF',
|
|
||||||
green: '#55FF55',
|
|
||||||
aqua: '#55FFFF',
|
|
||||||
red: '#FF5555',
|
|
||||||
light_purple: '#FF55FF',
|
|
||||||
yellow: '#FFFF55',
|
|
||||||
white: '#FFFFFF',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Some servers map custom fonts to real web fonts; extend this as needed */
|
|
||||||
const FONT_MAP: Record<string, string> = {
|
|
||||||
'minecraft:default': 'inherit',
|
|
||||||
'minecraft:uniform': 'inherit',
|
|
||||||
'minecraft:alt': "'Minecraft Enchantment', inherit",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_STYLE: ResolvedStyle = {
|
|
||||||
color: undefined,
|
|
||||||
bold: false,
|
|
||||||
italic: false,
|
|
||||||
underlined: false,
|
|
||||||
strikethrough: false,
|
|
||||||
obfuscated: false,
|
|
||||||
font: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a color field ("red" | "#RRGGBB" | "reset" | undefined) to a CSS color or
|
|
||||||
* undefined (meaning: unset / inherit).
|
|
||||||
*/
|
|
||||||
export function resolveColor(color: string | undefined): string | undefined {
|
|
||||||
if (!color) return undefined;
|
|
||||||
if (color === 'reset') return undefined;
|
|
||||||
if (color.startsWith('#')) return color;
|
|
||||||
return NAMED_COLORS[color] ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveFont(font: string | undefined): string | undefined {
|
|
||||||
if (!font) return undefined;
|
|
||||||
return FONT_MAP[font] ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges a node's own style declarations on top of its parent's resolved style.
|
|
||||||
* Adventure semantics: a field that is present on the node overrides the parent
|
|
||||||
* (including explicit `false`); a field that is absent (undefined) is inherited.
|
|
||||||
*/
|
|
||||||
export function mergeStyle(parent: ResolvedStyle | undefined, node: MiniMessageComponent): ResolvedStyle {
|
|
||||||
const base = parent ?? DEFAULT_STYLE;
|
|
||||||
return {
|
|
||||||
color: node.color !== undefined ? resolveColor(node.color) : base.color,
|
|
||||||
bold: node.bold !== undefined ? node.bold : base.bold,
|
|
||||||
italic: node.italic !== undefined ? node.italic : base.italic,
|
|
||||||
underlined: node.underlined !== undefined ? node.underlined : base.underlined,
|
|
||||||
strikethrough: node.strikethrough !== undefined ? node.strikethrough : base.strikethrough,
|
|
||||||
obfuscated: node.obfuscated !== undefined ? node.obfuscated : base.obfuscated,
|
|
||||||
font: node.font !== undefined ? resolveFont(node.font) : base.font,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Converts a resolved style into an Angular [ngStyle]-compatible object */
|
|
||||||
export function styleToNgStyle(style: ResolvedStyle): Record<string, string> {
|
|
||||||
const decorations: string[] = [];
|
|
||||||
if (style.underlined) decorations.push('underline');
|
|
||||||
if (style.strikethrough) decorations.push('line-through');
|
|
||||||
|
|
||||||
return {
|
|
||||||
color: style.color ?? 'inherit',
|
|
||||||
'font-weight': style.bold ? 'bold' : 'normal',
|
|
||||||
'font-style': style.italic ? 'italic' : 'normal',
|
|
||||||
'text-decoration-line': decorations.length ? decorations.join(' ') : 'none',
|
|
||||||
'font-family': style.font ?? 'inherit',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the node's own displayable text (does not recurse into `extra`) */
|
|
||||||
export function ownText(node: MiniMessageComponent): string {
|
|
||||||
if (node.text !== undefined) return node.text;
|
|
||||||
if (node.translate !== undefined) return node.fallback ?? `%${node.translate}%`;
|
|
||||||
if (node.keybind !== undefined) return `[${node.keybind}]`;
|
|
||||||
if (node.selector !== undefined) return node.selector;
|
|
||||||
if (node.score !== undefined) return node.score.value ?? '';
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recursively flattens a component tree (including `extra`) into plain text */
|
|
||||||
export function extractPlainText(node: MiniMessageComponent | string | undefined): string {
|
|
||||||
if (node === undefined) return '';
|
|
||||||
if (typeof node === 'string') return node;
|
|
||||||
let out = ownText(node);
|
|
||||||
if (node.extra) {
|
|
||||||
for (const child of node.extra) {
|
|
||||||
out += extractPlainText(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extracts plain text to use as a native `title` tooltip for a hoverEvent */
|
|
||||||
export function hoverEventToTitle(hover: MiniMessageHoverEvent | undefined): string | null {
|
|
||||||
if (!hover) return null;
|
|
||||||
if (hover.action === 'show_text') {
|
|
||||||
const source = hover.value ?? hover.contents;
|
|
||||||
if (typeof source === 'string') return source;
|
|
||||||
if (source && typeof source === 'object' && ('text' in source || 'translate' in source || 'extra' in source)) {
|
|
||||||
return extractPlainText(source as MiniMessageComponent);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (hover.action === 'show_item') {
|
|
||||||
const contents = hover.contents as Record<string, unknown> | undefined;
|
|
||||||
const id = (contents?.['id'] as string) ?? (hover.value as unknown as { id?: string })?.id;
|
|
||||||
return id ? `Item: ${id}` : 'Item';
|
|
||||||
}
|
|
||||||
if (hover.action === 'show_entity') {
|
|
||||||
const contents = hover.contents as Record<string, unknown> | undefined;
|
|
||||||
const type = (contents?.['type'] as string) ?? '';
|
|
||||||
return type ? `Entity: ${type}` : 'Entity';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-computes a component tree into a ProcessedMiniMessageNode tree.
|
|
||||||
* This resolves all styles, texts, and attributes once so they don't have to be
|
|
||||||
* re-computed on every render.
|
|
||||||
*/
|
|
||||||
export function precomputeNode(node: MiniMessageComponent, parentStyle?: ResolvedStyle): ProcessedMiniMessageNode {
|
|
||||||
const style = mergeStyle(parentStyle, node);
|
|
||||||
const isUrlLink = node.clickEvent?.action === 'open_url';
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: ownText(node),
|
|
||||||
style,
|
|
||||||
ngStyle: styleToNgStyle(style),
|
|
||||||
hoverTitle: hoverEventToTitle(node.hoverEvent),
|
|
||||||
isUrlLink,
|
|
||||||
hasNonUrlClick: !!node.clickEvent && !isUrlLink,
|
|
||||||
clickEvent: node.clickEvent,
|
|
||||||
insertion: node.insertion,
|
|
||||||
extra: (node.extra ?? []).map((child) => precomputeNode(child, style)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import {MiniMessageClickEvent, MiniMessageComponent, MiniMessageHoverEvent} from './mini-message.types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What actually arrives over the wire can be looser than the strict MiniMessageComponent
|
|
||||||
* type: some serializers emit bare strings inside `extra` instead of {text: "..."}, and
|
|
||||||
* some use snake_case event keys (hover_event/click_event) with different subfield names
|
|
||||||
* (e.g. click_event.command instead of clickEvent.value). This function tolerates both
|
|
||||||
* and always returns a clean, strictly-typed tree.
|
|
||||||
*
|
|
||||||
* Call this once, right after JSON.parse, before the data enters your app's state:
|
|
||||||
*
|
|
||||||
* messageJson: normalizeComponent(JSON.parse(m.messageJson))
|
|
||||||
*/
|
|
||||||
export function normalizeComponent(input: unknown): MiniMessageComponent {
|
|
||||||
if (typeof input === 'string') {
|
|
||||||
return {text: input};
|
|
||||||
}
|
|
||||||
if (input === null || typeof input !== 'object') {
|
|
||||||
return {text: ''};
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = input as Record<string, any>;
|
|
||||||
const node: MiniMessageComponent = {...(raw as MiniMessageComponent)};
|
|
||||||
|
|
||||||
node.clickEvent = normalizeClickEvent(raw['clickEvent'] ?? raw['click_event']);
|
|
||||||
node.hoverEvent = normalizeHoverEvent(raw['hoverEvent'] ?? raw['hover_event']);
|
|
||||||
delete node['click_event' as keyof MiniMessageComponent];
|
|
||||||
delete node['hover_event' as keyof MiniMessageComponent];
|
|
||||||
|
|
||||||
if (Array.isArray(raw['extra'])) {
|
|
||||||
node.extra = raw['extra'].map((child: unknown) => normalizeComponent(child));
|
|
||||||
}
|
|
||||||
|
|
||||||
// auditShape(node);
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeClickEvent(raw: Record<string, any> | undefined): MiniMessageClickEvent | undefined {
|
|
||||||
if (!raw) return undefined;
|
|
||||||
const value = raw['value'] ?? raw['command'] ?? raw['url'] ?? raw['page'] ?? '';
|
|
||||||
return {action: raw['action'], value: String(value)};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHoverEvent(raw: Record<string, any> | undefined): MiniMessageHoverEvent | undefined {
|
|
||||||
if (!raw) return undefined;
|
|
||||||
const source = raw['value'] ?? raw['contents'];
|
|
||||||
return {
|
|
||||||
action: raw['action'],
|
|
||||||
value: typeof source === 'string' ? source : source ? normalizeComponent(source) : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function auditShape(node: unknown, path = 'root'): void {
|
|
||||||
if (typeof node === 'string') {
|
|
||||||
console.warn(`Bare string in tree at ${path}:`, node);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const n = node as Record<string, unknown>;
|
|
||||||
if (n['hover_event'] || n['click_event']) {
|
|
||||||
console.warn(`snake_case event key at ${path}`, n);
|
|
||||||
}
|
|
||||||
(n['extra'] as unknown[] | undefined)?.forEach((c, i) => auditShape(c, `${path}.extra[${i}]`));
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import { Directive, ElementRef, Input, OnChanges, OnDestroy } from '@angular/core';
|
|
||||||
|
|
||||||
const OBFUSCATION_CHARS =
|
|
||||||
'!@#$%^&*()_+-=[]{}|;:,.<>?/~`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply as `[mmObfuscated]="style.obfuscated"` on an element whose textContent is the
|
|
||||||
* text to obfuscate. While active, cycles every character (except spaces) through random
|
|
||||||
* glyphs, mimicking Minecraft's "magic" formatting. The real text is preserved in an
|
|
||||||
* aria-label so it stays accessible to screen readers, and is restored to the DOM when
|
|
||||||
* obfuscation is turned off.
|
|
||||||
*/
|
|
||||||
@Directive({
|
|
||||||
selector: '[mmObfuscated]',
|
|
||||||
standalone: true,
|
|
||||||
})
|
|
||||||
export class ObfuscatedDirective implements OnChanges, OnDestroy {
|
|
||||||
@Input('mmObfuscated') active = false;
|
|
||||||
|
|
||||||
private original: string | null = null;
|
|
||||||
private intervalId: ReturnType<typeof setInterval> | undefined;
|
|
||||||
|
|
||||||
constructor(private readonly el: ElementRef<HTMLElement>) {}
|
|
||||||
|
|
||||||
ngOnChanges(): void {
|
|
||||||
if (this.original === null) {
|
|
||||||
this.original = this.el.nativeElement.textContent ?? '';
|
|
||||||
this.el.nativeElement.setAttribute('aria-label', this.original);
|
|
||||||
}
|
|
||||||
this.sync();
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sync(): void {
|
|
||||||
if (this.active && !this.intervalId) {
|
|
||||||
this.start();
|
|
||||||
} else if (!this.active && this.intervalId) {
|
|
||||||
this.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private start(): void {
|
|
||||||
this.intervalId = setInterval(() => {
|
|
||||||
const text = this.original ?? '';
|
|
||||||
const scrambled = text
|
|
||||||
.split('')
|
|
||||||
.map((ch) => (ch === ' ' ? ' ' : OBFUSCATION_CHARS[Math.floor(Math.random() * OBFUSCATION_CHARS.length)]))
|
|
||||||
.join('');
|
|
||||||
this.el.nativeElement.textContent = scrambled;
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stop(): void {
|
|
||||||
if (this.intervalId) {
|
|
||||||
clearInterval(this.intervalId);
|
|
||||||
this.intervalId = undefined;
|
|
||||||
}
|
|
||||||
if (this.original !== null) {
|
|
||||||
this.el.nativeElement.textContent = this.original;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user