144 Commits
Author SHA1 Message Date
stijn 660fd4eb9c Add mobile breakpoint detection to chat for responsive behavior and fullscreen prompt control 2026-07-19 18:57:19 +02:00
stijn 7138487912 Add fullscreen prompt for chat with enter/exit logic and styling adjustments 2026-07-19 18:55:53 +02:00
stijn ce6f59b543 Improve chat-box scrollbar styling with thin bars, transparency, and hover effects for better UI consistency. 2026-07-19 18:48:52 +02:00
stijn 915b64cdb0 Add per-server notification handling with server-specific enable/disable logic and message count in notifications. 2026-07-19 18:46:46 +02:00
stijn b415d2968d Add notification support with enable/disable functionality and sound effects for new messages. 2026-07-19 18:39:24 +02:00
stijn a3cb1247af Refine unread message count logic to reset only for the selected server. 2026-07-19 17:59:19 +02:00
stijn aca9bd45ab Reset unread message count for all servers when a new server is selected. 2026-07-19 17:58:31 +02:00
stijn 3f974b2dff Sort chat messages in descending order by timestamp for improved recency-based display. 2026-07-19 17:54:46 +02:00
stijn 51416aec01 Improve chat UI with consistent styling, hover effects, flexbox adjustments, and message container restructuring. Sort messages by timestamp for better readability. 2026-07-19 17:51:18 +02:00
stijn a5c6ab9020 Refine unread message count logic by excluding messages from the selected server. 2026-07-19 17:33:07 +02:00
stijn d61f2ed938 Modularize chat page with new components (chat-box, server-list, chat-info). Introduce server filtering, real-time updates, and redesigned layout for enhanced usability. 2026-07-19 17:12:03 +02:00
stijn 73132fae13 Fix circular dep 2026-07-19 15:17:30 +02:00
stijn 6996ecf48c Enhance chat view with server-specific colors, timestamp display, and improved message styling. 2026-07-19 01:36:13 +02:00
stijn 634de9a856 Update event name from 'chat-message' to 'chat' in chat service event listener. 2026-07-19 01:28:48 +02:00
stijn 7a360d1a26 Filter chat messages by allowed servers defined in application.properties. 2026-07-19 01:24:50 +02:00
stijn 0233958118 Normalize MiniMessage payloads with normalizeComponent utility and update message handling in chat view and service. 2026-07-19 01:19:12 +02:00
stijn 357948f134 Display "BLOCKED" label for messages with notBlocked set to false in chat view. 2026-07-19 01:11:23 +02:00
stijn 4b6a491919 Introduce MiniMessage rendering system for structured chat formatting and interaction 2026-07-19 00:59:04 +02:00
stijn 4708da0c43 Configure Tomcat remote IP settings and forward headers strategy for improved proxy support 2026-07-19 00:32:57 +02:00
stijn aa0c61cf5e Introduce Angular Chat Component with SSE integration for real-time updates. 2026-07-19 00:22:54 +02:00
stijn 04ca05cd14 Refactor ChatLogDao to use Timestamp and update ChatMessageMapper with proper JSON serialization using Jackson. 2026-07-19 00:12:12 +02:00
stijn 1eb0aac97e Update toJson method in ChatMessageMapper to include notBlocked field in JSON conversion. 2026-07-19 00:09:11 +02:00
stijn 5b28f2b707 Enable Spring Boot Actuator with limited exposure and secure /actuator/mappings endpoint access. 2026-07-19 00:08:46 +02:00
stijn dde66bd608 Introduce EventPublisher and EventController for server-sent events (SSE) to enable real-time chat updates. 2026-07-18 23:30:49 +02:00
stijn 646bbd4145 Introduce chat database integration with message persistence and initialization. 2026-07-18 22:57:55 +02:00
stijn 9ebd420874 Replace UnsupportedOperationException with HTTP 202 ACCEPTED response in sendChatMessages. 2026-07-18 21:02:11 +02:00
stijn 321e736d4b Add logging for debugging chat message addition, marked for future removal 2026-07-18 20:52:27 +02:00
stijn 837f87d9be Introduce Chat API with message handling, server state updates, and IP-based security restrictions. 2026-07-18 20:21:08 +02:00
stijn 536439f324 Replace best minecraft servers with Buzz Minecraft vote site 2026-03-29 18:24:01 +02:00
stijn 2a2525e205 Replace minecraft-servers with best minecraft servers vote site 2026-03-29 17:53:36 +02:00
stijn 9a47cb6652 Replace @Output with output decorator in HistoryComponent 2026-03-01 02:39:24 +01:00
stijn 8657a33e65 Replace @Input with input decorator in HistoryComponent 2026-03-01 02:38:29 +01:00
stijn d4b09a500f Replace @Input with input decorator in FormsComponent 2026-03-01 02:36:08 +01:00
stijn d67f1a4f87 Replace Input() with input.required in header and made map header full height 2026-03-01 02:34:12 +01:00
stijn 0113a38ace Assign default colors to particles without a defined color during data loading. 2026-02-08 19:54:51 +01:00
stijn 7d82beb1c7 Conditionally adjust size slider to display velocity slider based on supportsColor flag and introduce velocity property in particle management. 2026-02-08 18:45:53 +01:00
stijn 138c01cf25 Add support for default colors and color restrictions based on particle type in particle management. 2026-02-08 00:11:21 +01:00
stijn 0e4ce21c58 Remove unused notification methods in ParticleController and adjust size slider minimum value in particle component. 2026-02-07 23:39:41 +01:00
stijn 44d4cc1f05 Update .gitignore to exclude .env files 2026-02-01 21:39:51 +01:00
stijn fe3e532476 Pin Angular dependencies to version 20.2.2. 2026-01-25 20:15:59 +01:00
stijn b7fe37d9c4 Add toggle for enabling/disabling grid snap in RenderContainerComponent and update snapping logic. 2026-01-19 23:51:50 +01:00
stijn 984bbae7e3 Fix grid sizing/snap 2026-01-19 23:43:20 +01:00
stijn 58ea7d827e Add grid to particle drawer 2026-01-19 23:07:12 +01:00
Peter 87a75639cf Updated lag page regarding villagers and removed a feature from customfeatures 2026-01-15 20:23:46 +01:00
stijn 10e1f191b5 Bump ALTITUDE_VERSION to 1.21.10 2026-01-09 03:58:19 +01:00
stijn 6cb43b412c Comment out unused notification functionality in ParticleController since the file is directly readable 2026-01-04 05:37:46 +01:00
stijn af3ef7155a Make particles.file_path configurable via environment variable 2026-01-04 05:33:35 +01:00
stijn 8a0843128c Refactor particle management: remove secret key from file operations, add ParticleManagerComponent, update frame naming conventions, and enhance style and functionality. 2026-01-04 05:22:04 +01:00
stijn 63aa7fd550 Add toggle for showing/hiding the character in RenderContainerComponent and implement binding to PlayerModelService 2025-12-27 22:15:19 +01:00
stijn dc1b29e52c Add deepCopy utility and enforce required fields in particle forms 2025-12-27 22:06:45 +01:00
stijn 9b8c4891f4 Add toggle for showing only intersecting particles 2025-12-27 21:55:00 +01:00
stijn b4fcbed781 remove redundant theme parameter. 2025-12-27 21:28:16 +01:00
stijn dc1ed8ffee use inject, adjust properties order 2025-12-27 21:22:05 +01:00
stijn 57fb00f685 Remove unit test for RenderContainerComponent 2025-12-27 21:21:42 +01:00
stijn 08a42801df Update ParticleComponent title to "Particle input style" 2025-12-27 21:21:33 +01:00
stijn be78b66c5a Refactor ParticleComponent by replacing custom color picker with Angular Material form fields, adjusting style 2025-12-27 21:20:49 +01:00
stijn 37d7c37f3b Update particle-related styles and improve forms' structure in particle components. 2025-12-27 21:13:49 +01:00
stijn d9b60d8a94 Removable footer, compact styling for particle creator 2025-12-27 21:04:55 +01:00
stijn d1ff7b3f88 Apply inline code formatting for Discord usernames and user details in StaffApplicationDiscord and AppealDiscord to avoid formatting them. 2025-11-28 19:55:23 +01:00
stijn a6fbd19b6d Add loading state guard to prevent duplicate form submissions in Appeal and DiscordAppeal components. 2025-11-24 19:00:38 +01:00
stijn 525116e89b Enhance DiscordAppeal submission process by adding username retrieval and updating email notifications with Minecraft username. Refactor for clarity and consistency in variable usage. 2025-11-24 18:56:29 +01:00
stijn c56f5f9fe1 Prevent duplicate submissions in sendForm by adding a loading state guard and updating the submit button's disabled condition. 2025-11-24 01:56:36 +01:00
stijn a9e9f1f03a Add loading state guards to checkPunishment and onSubmit buttons to prevent duplicate actions and update visibility of related variables. 2025-11-24 01:55:13 +01:00
stijn beb5cd496a Prevent duplicate requests in checkPunishment and sendForm by adding loading state guards. 2025-11-24 01:54:18 +01:00
stijn 186a26fae1 Refactor AppealDiscord to simplify appeal list processing using Comparator and optimize imports. 2025-11-24 01:45:08 +01:00
stijn 9c0a298145 Annotate id and assignedTo parameters with @Param in mappers for improved MyBatis compatibility. 2025-11-24 01:39:12 +01:00
stijn 5622db917b Rename username to discordUsername in DiscordAppeal and update references for clarity. 2025-11-24 01:34:32 +01:00
stijn bfb656e033 Apply theme-based font color styling to staff playtime component for consistency with global design. 2025-11-24 01:30:50 +01:00
stijn ee83bab77e Simplify time display format in staff playtime component by adjusting hour abbreviation. 2025-11-24 01:19:24 +01:00
stijn bdad0ff0ae Refactor Angular Material table styles to use global theme-based CSS variables and remove redundant component-specific overrides. 2025-11-24 01:19:19 +01:00
stijn 2bc5c41435 Add sorting functionality to staff playtime table and include roles in UI and database mapping 2025-11-24 01:08:43 +01:00
stijn fb01fc7571 Add staff role mapping, display role in UI, and enhance staff playtime calculations 2025-11-24 00:49:56 +01:00
stijn 1d76895cbb Prevent username retrieval if user is not authenticated and fix variable naming in DiscordAppealMapper. 2025-11-23 05:03:42 +01:00
stijn d69ef2cd20 Adjust checkAuthStatus timing and introduce reloadUsername on service initialization. 2025-11-23 04:48:37 +01:00
stijn 9ab0a130ed Delay checkAuthStatus execution on auth service initialization and add logging for username retrieval and errors. 2025-11-23 04:46:58 +01:00
stijn b15386d157 Add DiscordAppealMapper to initialization and simplify username reload logic in auth service. 2025-11-23 04:44:26 +01:00
stijn 2baa3ef51f Fix incorrect condition in Discord appeal form banning status check. 2025-11-23 04:31:16 +01:00
stijn bfed460d8e Add detailed logging for user ban status in DiscordAppealDiscord 2025-11-23 04:21:12 +01:00
stijn 2e7c91bb73 Change discordId type from integer to string across frontend, backend, and API schema for consistency and proper validation. 2025-11-23 04:14:21 +01:00
stijn ea4780cc91 Fix minlength and maxlength being reversed. 2025-11-23 03:59:38 +01:00
stijn a6813129bb Add logging for user ban retrieval in DiscordAppealDiscord and annotate class with @Slf4j. 2025-11-23 03:48:20 +01:00
stijn 20c89a4f8e Refactor Discord token retrieval by prioritizing environment variable and update lambda formatting in DiscordSender. 2025-11-23 03:45:28 +01:00
stijn 1bf08fb4fc Refactor DiscordBotInstance to remove start method from public API, initialize JDA with lazy loading, and clean up unused token validation logic. 2025-11-23 03:40:53 +01:00
stijn af9e1e627f Refactor DiscordBotInstance to initialize JDA lazily and standardize "Discord ID" terminology in appeal form. 2025-11-23 03:34:20 +01:00
stijn 7d59885395 Implement Discord appeal functionality, including database schema, API endpoints, front-end form, and Discord message handling. 2025-11-22 22:26:40 +01:00
stijn 20ec3648c4 Refactor AppealComponent to remove dynamic height logic, integrate FullSizeComponent, and simplify imports. 2025-11-22 01:22:15 +01:00
stijn 5876298ae9 Simplify SentComponent template by removing unnecessary <ng-content> wrapper. 2025-11-22 01:19:46 +01:00
stijn da3a818f03 Add FullSizeComponent for dynamic height adjustment and update SentComponent to use it 2025-11-22 01:17:54 +01:00
stijn 9311a1ccd6 Refactor appeal message sending to use AppealSender and improve assignment handling with thread creation and button interactions. 2025-11-22 00:56:18 +01:00
stijn 65820cf0a4 Implement appeal assignment system with AppealListMapper and associated logic. 2025-11-22 00:44:18 +01:00
stijn 9d23838eb0 Remove email information from Discord appeal message. 2025-11-22 00:17:03 +01:00
stijn a50b4ed658 Handle WARN case in EditHistoryMapper by updating "litebans_warnings" instead of throwing an exception. 2025-11-22 00:13:11 +01:00
stijn 0f11167953 Refactor Discord message sending to use MessageForEmbed object and add support for creating threads in targeted channels. 2025-11-21 23:39:35 +01:00
stijn ec3435dccc Add grove-dl route and corresponding redirect to MediaFire folder in RedirectComponent 2025-11-15 20:50:18 +01:00
stijn 07048567a1 Set body background color in RedirectComponent styles to use secondary theme color 2025-11-13 21:24:20 +01:00
stijn 5d9bf922a4 Update RedirectComponent to use window.location.href for external navigation instead of router.navigateByUrl. 2025-11-13 21:18:10 +01:00
stijn 19bc6fc8e3 Create RedirectComponent to handle dynamic redirections and update routes for improved maintainability. 2025-11-13 21:10:38 +01:00
stijn 42786dce74 Add route to redirect /worlddl path to external MediaFire link 2025-11-13 20:59:28 +01:00
stijn e415ecc415 Implement dynamic container height adjustment in NickGeneratorComponent based on header and footer dimensions. Refactor HTML structure for improved dark mode styling and accessibility. Optimize component lifecycle by adding AfterViewInit and OnDestroy handling with a ResizeObserver. 2025-11-08 22:09:35 +01:00
stijn 72b9109ece Configure additional assets output path in angular.json. 2025-11-08 21:40:53 +01:00
stijn 19f37e7dd7 Correct misassigned section classes in CommunityComponent HTML for consistent styling. 2025-11-08 21:23:06 +01:00
stijn 7ce9ee33c4 Add "Developers" section to CommunityComponent with dynamic team member display and adjust structure of existing sections. 2025-11-08 21:21:53 +01:00
stijn a1fbdf3581 Add "Developers" section to CommunityComponent with dynamic team member display and adjust structure of existing sections. 2025-11-08 21:20:25 +01:00
stijn a05a751628 Remove redundant unit tests for CommunityComponent and RanksComponent, enhance community.component with dynamic team member display and toggle functionality, update routing for community links, and set stricter field constraints in team schema. 2025-11-08 18:45:03 +01:00
stijn 042a6450c2 Fix spacing 2025-11-08 18:32:49 +01:00
stijn 724b773be5 Refactor createPrivilegedUser to accept PrivilegedUser object and adjust LoginController for consistency. 2025-11-08 18:21:21 +01:00
stijn 7315ea8455 Navigate to root path after successful login via login/:code route in AuthGuard. 2025-11-08 18:06:13 +01:00
stijn b7c553acc1 Add login/:code route with AuthGuard and required authorizations 2025-11-08 18:02:34 +01:00
stijn 5ab81ee66e Switch to @SelectKey for id generation in createPrivilegedUser and remove unused @Nullable import. 2025-11-08 17:58:03 +01:00
stijn e83d109012 Add route parameter handling for appeal paths and enhance AuthGuard to support login via code query parameter 2025-11-08 17:42:29 +01:00
stijn e8f952e7e2 Ensure all staff members are included in playtime mapping by adding default playtime data for missing UUIDs. 2025-11-02 23:15:12 +01:00
stijn ff85b42190 Highlight playtime under threshold in red in Staff Playtime view. 2025-11-02 23:12:04 +01:00
stijn 0a96593992 Simplify "Playtime" column header in Staff Playtime view. 2025-11-02 23:09:27 +01:00
stijn 795bd22ee9 Adjust loadStaffData method to handle timezones accurately in Staff Playtime component. 2025-11-02 23:06:12 +01:00
stijn 83893f947d Center Staff Playtime container for improved layout alignment. 2025-11-02 23:04:15 +01:00
stijn 9a039e1e10 Limit Staff Playtime component width to improve layout consistency. 2025-11-02 23:03:25 +01:00
stijn 2bdebb71b7 Add rate limiting to getStaffPlaytime and getVoteStats endpoints 2025-11-02 23:03:00 +01:00
stijn 39b7a398a5 Refactor minutesToHm method in Staff Playtime component for improved readability by adding explicit braces to conditional blocks. 2025-11-02 22:58:47 +01:00
stijn 06a1cd64e3 Remove "Last Played" column from Staff Playtime view and enhance time formatting in minutesToHm method to include days. 2025-11-02 22:58:21 +01:00
stijn 6292d0cacf Update weekStart to dynamically use the current date instead of a fixed one in Staff Playtime component 2025-11-02 22:54:45 +01:00
stijn 8b4f1c2785 Wrap weekLabel in a <span> for improved styling and DOM structure consistency in Staff Playtime view. 2025-11-02 22:52:50 +01:00
stijn 710771f5f7 Integrate HeaderComponent into Staff Playtime view, update layout with full-height styling, and enhance UI consistency. 2025-11-02 22:48:27 +01:00
stijn edaebe9e4a Fix getTeamMembers query to scope permissions to the global server 2025-11-02 22:46:26 +01:00
stijn e43cbbf9e4 WIP staff pt 2025-11-02 22:36:28 +01:00
stijn 8b0d2f9203 Add staff playtime feature, including backend services, API endpoint, and frontend integration.
WIP
2025-11-02 22:25:10 +01:00
stijn 2be79c180a Refactor Nickname Generator component with Angular Material, update logic for fields and commands, and improve styling. 2025-10-29 21:39:39 +01:00
stijn 423d5e4a4c Fix getTeamMembers query to scope permissions to the global server 2025-10-26 01:45:19 +02:00
stijn a0db55dede Fix getTeamMembers query to scope permissions to the global server 2025-10-26 01:43:00 +02:00
stijn e0a09d303c Update vote eligibility logic, add MatIconModule, and refine button styles for improved clarity. 2025-10-24 22:43:38 +02:00
stijn 29967d65b8 Improve vote eligibility check by adding find to handle cases where voteSite is not found. 2025-10-24 22:28:36 +02:00
stijn 8b265514a6 Refactor RateLimitAspect to use authenticated UUID instead of client IP for rate limiting. Enhance AuthenticatedUuid with optional UUID retrieval method. 2025-10-24 22:27:04 +02:00
stijn e766fd1125 Fix vote eligibility check logic by correcting timestamp comparison direction. 2025-10-24 22:22:32 +02:00
stijn 86a85049b3 center p 2025-10-24 22:20:50 +02:00
stijn cf73303218 center div 2025-10-24 22:18:19 +02:00
stijn d075464ded center div 2025-10-24 22:16:58 +02:00
stijn 7be3b6f9d3 Wrap vote statistics message in a centered <div> for improved alignment and readability. 2025-10-24 22:15:30 +02:00
stijn 24e28015d3 Adjust vote refresh interval to 1 minute and update vote disclaimer text. Add vote statistics display. 2025-10-24 22:13:44 +02:00
stijn 754479eb98 Refactor: move actor and actorUuid UUID retrieval to method to thread with auth 2025-10-24 22:01:12 +02:00
stijn 5974ec1dba Revert "Refactor getAuthenticatedUserUuid - extract getAuthentication method for improved null handling and clarity"
This reverts commit 4b466f314e.
2025-10-24 21:59:57 +02:00
stijn c5ed657d3e Revert "Enhance AuthenticatedUuid to improve UUID extraction by adding support for decoding tokens from the Authorization header. Add logging, refactor for better null handling, and introduce @RequiredArgsConstructor."
This reverts commit 02adbb2522.
2025-10-24 21:59:57 +02:00
stijn 02adbb2522 Enhance AuthenticatedUuid to improve UUID extraction by adding support for decoding tokens from the Authorization header. Add logging, refactor for better null handling, and introduce @RequiredArgsConstructor. 2025-10-24 21:58:07 +02:00
stijn 4b466f314e Refactor getAuthenticatedUserUuid - extract getAuthentication method for improved null handling and clarity 2025-10-24 21:46:07 +02:00
stijn 6531526278 Add logging for invalid authentication principal in AuthenticatedUuid and annotate with @Slf4j 2025-10-24 21:43:50 +02:00
stijn bc0739f707 Fix table name casing in VotingPluginUsersMapper query 2025-10-24 21:34:54 +02:00
185 changed files with 6315 additions and 1104 deletions
+2
View File
@@ -91,3 +91,5 @@ generated
liquibase*.properties liquibase*.properties
node node
.env
+1
View File
@@ -43,6 +43,7 @@ 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,9 +4,11 @@ import com.alttd.altitudeweb.setup.Connection;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication(scanBasePackages = {"com.alttd.altitudeweb"}) @SpringBootApplication(scanBasePackages = {"com.alttd.altitudeweb"})
@EnableAspectJAutoProxy @EnableAspectJAutoProxy
@EnableScheduling
public class AltitudeWebApplication { public class AltitudeWebApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -10,6 +10,7 @@ import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jose.proc.SecurityContext;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
@@ -26,6 +27,7 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPrivateKey;
@@ -42,18 +44,31 @@ 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/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.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()
.anyRequest().permitAll() .anyRequest().permitAll()
@@ -0,0 +1,34 @@
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");
}
}
@@ -0,0 +1,20 @@
package com.alttd.altitudeweb.controllers.chat;
import lombok.Builder;
import lombok.Getter;
import java.time.Instant;
import java.util.UUID;
@Builder
@Getter
public class ChatMessage {
private final UUID uuid;
private final Instant timestamp;
private final String server;
//TODO [Stijn] [2026-07-18]: Handle channel types
//private final String channel;
private final String messageJson;
private final boolean notBlocked;
}
@@ -0,0 +1,45 @@
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.datatype.jsr310.JavaTimeModule;
import lombok.experimental.UtilityClass;
import java.time.Instant;
import java.util.UUID;
@UtilityClass
public class ChatMessageMapper {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule());
public ChatMessage fromDto(ChatMessageDto dto) {
return ChatMessage.builder()
.uuid(dto.getUuid())
.timestamp(dto.getTimestamp().toInstant())//TODO [Stijn] [2026-07-18]: Check this works, might be sending instant instead
.server(dto.getServer())
.messageJson(dto.getMessage())
.notBlocked(!dto.getBlocked())
.build();
}
public ChatMessage fromDao(ChatLogDao dao) {
return ChatMessage.builder()
.uuid(UUID.fromString(dao.getUuid()))
.timestamp(dao.getTimeStamp().toInstant())
.server(dao.getServer())
.messageJson(dao.getMiniMessage())
.notBlocked(!dao.isBlocked())
.build();
}
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,5 +1,7 @@
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;
@@ -8,8 +10,10 @@ 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}}")
@@ -25,6 +29,9 @@ 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");
} }
@@ -39,4 +46,28 @@ 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();
}
}
} }
@@ -0,0 +1,67 @@
package com.alttd.altitudeweb.controllers.event;
import com.alttd.altitudeweb.controllers.chat.ChatMessageMapper;
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 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.UUID;
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;
private final EventPublisher eventPublisher;
@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 || !authorities.contains(PermissionClaimDto.HEAD_MOD.getValue())) {
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(",", "[", "]"));
return eventPublisher.subscribe(new EventUser(subject, authorities), json);
}
}
@@ -10,15 +10,14 @@ 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.DiscordAppealDto; import com.alttd.altitudeweb.model.*;
import com.alttd.altitudeweb.model.FormResponseDto; import com.alttd.altitudeweb.services.forms.DiscordAppeal;
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;
@@ -36,12 +35,19 @@ 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) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported"); return new ResponseEntity<>(discordAppeal.submitAppeal(discordAppealDto), HttpStatus.OK);
} }
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal") @RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
@@ -12,7 +12,6 @@ import com.alttd.altitudeweb.model.PunishmentHistoryDto;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@@ -242,6 +241,8 @@ 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);
@@ -253,7 +254,6 @@ 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,6 +275,8 @@ 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);
@@ -286,7 +288,6 @@ 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);
@@ -311,6 +312,8 @@ 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);
@@ -320,7 +323,6 @@ 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,15 +210,16 @@ 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> privilegedUser = mapper Optional<PrivilegedUser> optionalPrivilegedUser = mapper
.getUserByUuid(uuid); .getUserByUuid(uuid);
if (privilegedUser.isEmpty()) { if (optionalPrivilegedUser.isEmpty()) {
int privilegedUserId = mapper.createPrivilegedUser(uuid); PrivilegedUser privilegedUser = new PrivilegedUser(null, uuid, List.of());
mapper.createPrivilegedUser(privilegedUser);
privilegedUserCompletableFuture.complete( privilegedUserCompletableFuture.complete(
Optional.of(new PrivilegedUser(privilegedUserId, uuid, List.of()))); Optional.of(privilegedUser));
} else { } else {
privilegedUserCompletableFuture.complete(privilegedUser); privilegedUserCompletableFuture.complete(optionalPrivilegedUser);
} }
} 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,34 +24,27 @@ 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 authorization, String filename) throws Exception { public ResponseEntity<Resource> downloadFile(String filename) {
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();
} }
File targetFile = new File(file, filename); return getFileForDownload(file, filename);
return getFileForDownload(targetFile, filename);
} }
@Override @Override
public ResponseEntity<Resource> downloadFileForUser(String authorization, String uuid, String filename) throws Exception { public ResponseEntity<Resource> downloadFileForUser(String uuid, String filename) {
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);
@@ -67,6 +60,7 @@ 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());
@@ -95,19 +89,17 @@ public class ParticleController implements ParticlesApi {
} }
@Override @Override
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) throws Exception { public ResponseEntity<Void> saveFile(String filename, MultipartFile content) {
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();
} }
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content); return writeContentToFile(file, filename, content);
notifyServerOfFileUpload(filename);
return voidResponseEntity;
} }
@Override @Override
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) throws Exception { public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) {
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);
@@ -121,39 +113,12 @@ public class ParticleController implements ParticlesApi {
} }
} }
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content); return 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,15 +2,21 @@ 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;
@@ -23,8 +29,22 @@ 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);
@@ -0,0 +1,14 @@
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());
}
}
@@ -0,0 +1,26 @@
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);
}
}
@@ -0,0 +1,70 @@
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;
}
}
@@ -0,0 +1,92 @@
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.model.PermissionClaimDto;
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
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 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);
@Value("${chat.allowed-servers}")
private String[] allowedServers;
private final NavigableMap<Instant, ChatMessage> chatMessages = new TreeMap<>();
private final EventPublisher eventPublisher;
@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);
}
});
}
private void putChatMessage(ChatMessage chatMessage) {
chatMessages.put(chatMessage.getTimestamp(), chatMessage);
}
public void addChatMessage(List<ChatMessage> chatMessageList) {
chatMessageList.forEach(this::putChatMessage);
sendMessagesToListeners(chatMessageList);
}
private void sendMessagesToListeners(List<ChatMessage> chatMessageList) {
String jsonMessageListIncludingBlocked = chatMessageList.stream()
.map(ChatMessageMapper::toJson)
.collect(Collectors.joining(",", "[", "]"));
String jsonMessageList = chatMessageList.stream()
.filter(ChatMessage::isNotBlocked)
.filter(chatMessage -> Arrays.asList(allowedServers).contains(chatMessage.getServer()))
.map(ChatMessageMapper::toJson)
.collect(Collectors.joining(",", "[", "]"));
eventPublisher.sendToUsers("chat", (eventUser) -> {
if (eventUser.hasPermission(PermissionClaimDto.HEAD_MOD)) {
return jsonMessageListIncludingBlocked;
}
return jsonMessageList;
});
}
public List<ChatMessage> getMessagesSince(Instant instant) {
return chatMessages.tailMap(instant, false).values().stream().toList();
}
}
@@ -0,0 +1,103 @@
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.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
@RequiredArgsConstructor
@Service
@Slf4j
public class EventPublisher {
private final Map<UUID, EventUser> eventUserMap = new ConcurrentHashMap<>();
private final Map<UUID, List<SseEmitter>> emitterMap = new ConcurrentHashMap<>();
public SseEmitter subscribe(EventUser eventUser, String initialData) {
SseEmitter emitter = new SseEmitter(0L); // no built-in timeout; heartbeat governs liveness
emitterMap.computeIfAbsent(eventUser.uuid(), k -> new CopyOnWriteArrayList<>()).add(emitter);
eventUserMap.put(eventUser.uuid(), eventUser);
log.info("User {} subscribed, active emitters: {}", eventUser.uuid(), countActive());
Runnable cleanup = () -> {
List<SseEmitter> userEmitters = emitterMap.get(eventUser.uuid());
if (userEmitters != null) {
userEmitters.remove(emitter);
if (userEmitters.isEmpty()) {
emitterMap.remove(eventUser.uuid());
eventUserMap.remove(eventUser.uuid());
}
}
log.info("Cleaned up emitter for {}, active emitters: {}", eventUser.uuid(), countActive());
};
// covers: clean disconnect (tab closed normally), server-side timeout, write failure
emitter.onCompletion(cleanup);
emitter.onTimeout(cleanup);
emitter.onError(e -> cleanup.run());
try {
emitter.send(SseEmitter.event().name("connected").data(initialData));
} 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(UUID uuid, String eventName, String json) {
List<SseEmitter> userEmitters = emitterMap.get(uuid);
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();
}
public void sendToUsers(String eventName, MessageForUser messagesForUser) {
eventUserMap.values().forEach(emitter -> {
String json = messagesForUser.get(emitter);
sendToUser(emitter.uuid(), eventName, json);
});
}
}
@@ -0,0 +1,28 @@
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) {
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);
}
}
@@ -0,0 +1,8 @@
package com.alttd.altitudeweb.services.chat.event_publisher;
@FunctionalInterface
public interface MessageForUser {
String get(EventUser eventUser);
}
@@ -1,6 +1,8 @@
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;
@@ -8,7 +10,12 @@ 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;
@@ -16,8 +23,7 @@ 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.ArrayList; import java.util.*;
import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@Slf4j @Slf4j
@@ -26,19 +32,67 @@ 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 = new CompletableFuture<>(); CompletableFuture<List<OutputChannel>> channelsFuture = getChannelListFuture();
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());
@@ -64,9 +118,8 @@ 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
)); ));
@@ -88,6 +141,22 @@ 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());
@@ -97,15 +166,44 @@ 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();
DiscordSender.getInstance().sendEmbedToChannels( MessageForEmbed newAppealSubmitted = new MessageForEmbed(
channelIds, "New Appeal Submitted", description, fields, null, timestamp, null);
"New Appeal Submitted", AppealSender.getInstance().sendAppeal(channelIds, newAppealSubmitted, optionalAssignedTo.orElse(0L));
description, }
fields,
null, private static CompletableFuture<List<OutputChannel>> getChannelListFuture() {
timestamp, CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
null 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<>());
}
});
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) {
@@ -132,4 +230,50 @@ 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,6 +5,7 @@ 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;
@@ -46,8 +47,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()),
@@ -79,16 +80,15 @@ public class StaffApplicationDiscord {
.toList(); .toList();
Instant timestamp = application.createdAt() != null ? application.createdAt() : Instant.now(); Instant timestamp = application.createdAt() != null ? application.createdAt() : Instant.now();
DiscordSender.getInstance().sendEmbedToChannels( MessageForEmbed messageForEmbed = new MessageForEmbed(
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) {
@@ -0,0 +1,138 @@
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,5 +1,6 @@
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;
@@ -16,6 +17,8 @@ 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
@@ -24,6 +27,7 @@ import java.time.Duration;
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)
@@ -37,7 +41,6 @@ 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();
@@ -54,7 +57,12 @@ 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();
String key = clientIp + "-" + (customKey.isEmpty() ? method.getName() : customKey); Optional<UUID> optionalUUID = authenticatedUuid.tryGetAuthenticatedUserUuid();
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);
@@ -67,7 +75,7 @@ public class RateLimitAspect {
return joinPoint.proceed(); return joinPoint.proceed();
} else { } else {
log.warn("Rate limit exceeded for IP: {}, endpoint: {}", clientIp, request.getRequestURI()); log.warn("Rate limit exceeded for uuid: {}, endpoint: {}", uuid, request.getRequestURI());
Duration nextResetTime = rateLimiterService.getNextResetTime(key, duration); Duration nextResetTime = rateLimiterService.getNextResetTime(key, duration);
@@ -2,6 +2,7 @@ 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;
@@ -29,6 +30,20 @@ public class AppealMail {
private static final String APPEAL_EMAIL = "[email protected]"; private static final String APPEAL_EMAIL = "[email protected]";
/**
* 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.
* *
@@ -66,4 +81,31 @@ 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;
}
} }
@@ -0,0 +1,83 @@
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=${user.home}/.altitudeweb/particles particles.file_path=${PARTICLE_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,3 +18,9 @@ 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
@@ -114,8 +114,7 @@
<div class="columnContainer"> <div class="columnContainer">
<div> <div>
<h2>Appeal:</h2> <h2>Appeal:</h2>
<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. <p th:text="${appeal.reason}">appeal</p>
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>
@@ -0,0 +1,122 @@
<!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>
@@ -8,7 +8,9 @@ public enum Databases {
LUCK_PERMS("luckperms"), LUCK_PERMS("luckperms"),
LITE_BANS("litebans"), LITE_BANS("litebans"),
DISCORD("discordLink"), DISCORD("discordLink"),
VOTING_PLUGIN("votingplugin"); PROXY_PLAYTIME("proxyplaytime"),
VOTING_PLUGIN("votingplugin"),
CHAT("chat");
private final String internalName; private final String internalName;
@@ -0,0 +1,18 @@
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 miniMessage;
private final boolean blocked;
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.database.chat;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface ChatLogMapper {
@Select("""
SELECT uuid, time_stamp, server, mini_message, blocked
FROM chat_log
WHERE mini_message IS NOT NULL
AND time_stamp >= #{since}
""")
List<ChatLogDao> getChatLogs(@Param("since") long since);
}
@@ -0,0 +1,4 @@
package com.alttd.altitudeweb.database.discord;
public record AppealList(Long userId, boolean next) {
}
@@ -0,0 +1,25 @@
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 -> throw new IllegalArgumentException("WARN has no until"); case WARN -> updateUntil("litebans_warnings", id, until);
}; };
} }
@@ -0,0 +1,6 @@
package com.alttd.altitudeweb.database.luckperms;
import java.util.UUID;
public record PlayerWithGroup(String username, UUID uuid, String group) {
}
@@ -15,8 +15,24 @@ 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} WHERE permission = #{groupPermission} AND server = 'global'
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);
} }
@@ -0,0 +1,33 @@
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);
}
@@ -0,0 +1,6 @@
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,7 +1,6 @@
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;
@@ -103,9 +102,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 (#{uuid}) VALUES (#{user.uuid})
""") """)
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") @SelectKey(statement = "SELECT LAST_INSERT_ID()", keyProperty = "user.id", before = false, resultType = int.class)
int createPrivilegedUser(UUID uuid); void createPrivilegedUser(@Param("user") PrivilegedUser user);
} }
@@ -1,6 +1,7 @@
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;
@@ -33,5 +34,11 @@ public interface AppealMapper {
UPDATE appeals SET send_at = NOW() UPDATE appeals SET send_at = NOW()
WHERE id = #{id} WHERE id = #{id}
""") """)
void markAppealAsSent(UUID id); void markAppealAsSent(@Param("id") UUID id);
@Update("""
UPDATE appeals SET assigned_to = #{assignedTo}
WHERE id = #{id}
""")
void assignAppeal(@Param("id") UUID id, @Param("assignedTo") Long assignedTo);
} }
@@ -0,0 +1,17 @@
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
) {
}
@@ -0,0 +1,28 @@
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,8 +37,10 @@ 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
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.chat.ChatLogMapper;
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper;
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);
}).join();
log.debug("Initialized Chat");
}
}
@@ -1,6 +1,7 @@
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;
@@ -12,6 +13,7 @@ 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");
} }
@@ -0,0 +1,18 @@
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");
}
}
@@ -5,6 +5,7 @@ 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.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;
@@ -24,6 +25,7 @@ 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);
}).join() }).join()
@@ -33,6 +35,7 @@ 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);
}); });
@@ -183,4 +186,26 @@ 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);
}
}
} }
@@ -7,12 +7,7 @@ import lombok.extern.slf4j.Slf4j;
public class DiscordBot { public class DiscordBot {
public static void main(String[] args) { public static void main(String[] args) {
String discordToken = System.getProperty("DISCORD_TOKEN"); DiscordBotInstance discordBotInstance = DiscordBotInstance.getInstance();
if (discordToken == null) { discordBotInstance.getJda();
log.error("Discord token not found, put it in the DISCORD_TOKEN environment variable");
System.exit(1);
}
DiscordBotInstance discordBotInstance = new DiscordBotInstance();
discordBotInstance.start(discordToken);
} }
} }
@@ -0,0 +1,60 @@
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();
});
}
}
@@ -0,0 +1,14 @@
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());
}
}
@@ -0,0 +1,4 @@
package com.alttd.webinterface.appeals;
public record BannedUser(long userId, String reason, String name, String avatarUrl) {
}
@@ -0,0 +1,43 @@
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,14 +6,41 @@ 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 {
@Getter private static final DiscordBotInstance INSTANCE = new DiscordBotInstance();
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 synchronized void start(String token) { public JDA getJda() {
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;
} }
@@ -0,0 +1,49 @@
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,26 +1,28 @@
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.EmbedBuilder; import net.dv8tion.jda.api.entities.Message;
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.awt.*; import java.util.ArrayList;
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 = new DiscordBotInstance(); private final DiscordBotInstance botInstance = DiscordBotInstance.getInstance();
private DiscordSender() {} private DiscordSender() {}
@@ -28,19 +30,7 @@ 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;
@@ -70,12 +60,36 @@ public class DiscordSender {
}); });
} }
public void sendEmbedToChannels(List<Long> channelIds, String title, String description, List<EmbedField> fields, public void sendEmbedWithThreadToChannels(List<Long> channelIds, MessageForEmbed messageForEmbed, String threadName) {
Integer colorRgb, Instant timestamp, String footer) { sendEmbedToChannels(channelIds, messageForEmbed).whenCompleteAsync((result, error) -> {
ensureStarted(); if (error != null) {
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; return CompletableFuture.completedFuture(Optional.empty());
} }
try { try {
if (!botInstance.isReady()) { if (!botInstance.isReady()) {
@@ -83,43 +97,31 @@ 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());
} }
EmbedBuilder eb = new EmbedBuilder(); MessageEmbed embed = messageForEmbed.toEmbed();
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) { TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, channelId);
for (EmbedField f : fields) { if (channel == null) {
if (f == null) continue; log.warn("TextChannel with id {} not found when sending embed message", channelId);
String name = f.getName() == null ? "" : f.getName(); return CompletableFuture.completedFuture(Optional.empty());
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<>();
MessageEmbed embed = eb.build(); channel.sendMessageEmbeds(embed).queue(
message -> {
channelIds.stream() completableFuture.complete(Optional.of(message));
.filter(Objects::nonNull) log.debug("Sent embed to channel {}", channelId);
.forEach(id -> { },
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id); error -> {
if (channel == null) { completableFuture.complete(Optional.empty());
log.warn("TextChannel with id {} not found", id); log.error("Failed sending embed to channel {}", channelId, error);
return; }
} );
channel.sendMessageEmbeds(embed).queue( return completableFuture;
success -> log.debug("Sent embed to channel {}", id),
error -> log.error("Failed sending embed to channel {}", id, error)
);
});
} }
@Data @Data
+5
View File
@@ -30,6 +30,11 @@
"glob": "**/*", "glob": "**/*",
"input": "public", "input": "public",
"output": "public" "output": "public"
},
{
"glob": "**/*",
"input": "public",
"output": "assets"
} }
], ],
"styles": [ "styles": [
+15 -13
View File
@@ -13,17 +13,18 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^20.1.3", "@angular/cdk": "20.2.2",
"@angular/common": "^20.1.0", "@angular/common": "20.2.2",
"@angular/compiler": "^20.1.0", "@angular/compiler": "20.2.2",
"@angular/core": "^20.1.0", "@angular/core": "20.2.2",
"@angular/forms": "^20.1.0", "@angular/forms": "20.2.2",
"@angular/material": "^20.1.3", "@angular/material": "20.2.2",
"@angular/platform-browser": "^20.1.0", "@angular/platform-browser": "20.2.2",
"@angular/platform-browser-dynamic": "^20.1.0", "@angular/platform-browser-dynamic": "20.2.2",
"@angular/router": "^20.1.0", "@angular/router": "20.2.2",
"@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",
@@ -31,9 +32,10 @@
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^20.1.0", "@angular/build": "20.2.2",
"@angular/cli": "^20.1.0", "@angular/cli": "20.2.2",
"@angular/compiler-cli": "^20.1.0", "@angular/compiler-cli": "20.2.2",
"@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",
@@ -41,6 +43,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.8.3" "typescript": "^5.9.2"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"/api": { "/api": {
"target": "http://localhost:8080", "target": "http://10.0.0.121:8080",
"secure": false, "secure": false,
"changeOrigin": true "changeOrigin": true
} }
Binary file not shown.
-29
View File
@@ -1,29 +0,0 @@
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');
});
});
+4 -5
View File
@@ -1,7 +1,7 @@
import {Component, OnInit} from '@angular/core'; import {Component, inject, 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 {Router, RouterOutlet} from '@angular/router'; import {RouterOutlet} from '@angular/router';
import {FooterComponent} from '@pages/footer/footer/footer.component'; import {FooterComponent} from '@pages/footer/footer/footer.component';
@Component({ @Component({
@@ -27,9 +27,8 @@ 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'
constructor(private titleService: Title, private metaService: Meta, private router: Router) { private readonly titleService = inject(Title);
private readonly metaService = inject(Meta);
}
ngOnInit(): void { ngOnInit(): void {
this.titleService.setTitle(this.title) this.titleService.setTitle(this.title)
+58 -4
View File
@@ -2,6 +2,28 @@ 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)
@@ -14,6 +36,14 @@ 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)
@@ -110,18 +140,38 @@ 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: { data: {requiredAuthorizations: ['SCOPE_user']}
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',
@@ -148,12 +198,16 @@ 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/nickgenerator.component').then(m => m.NickgeneratorComponent) loadComponent: () => import('@pages/reference/nickgenerator/nick-generator.component').then(m => m.NickGeneratorComponent)
}, },
]; ];
+19 -1
View File
@@ -1,9 +1,10 @@
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 {map, Observable} from 'rxjs'; import {from, isObservable, map, Observable, of, switchMap} 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'
@@ -21,6 +22,23 @@ 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, {
@@ -0,0 +1,45 @@
<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="server-list">
<app-server-list [servers]="servers()" [selectedServer]="selectedServer()"
(selectedServerChange)="onSelectedServerChange($event)">
</app-server-list>
</div>
<div class="chat">
<div class="chat-info">
<app-chat-info [selectedServer]="selectedServer()"></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>
@@ -0,0 +1,83 @@
.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%;
.server-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;
}
}
}
@@ -0,0 +1,87 @@
import {AfterViewInit, Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
import {Subscription} from 'rxjs';
import {ChatService} from '@pages/altitude/chat/service/chat.service';
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 {ServerListComponent} from '@pages/altitude/chat/components/server-list/server-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,
ServerListComponent,
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 servers = this.chatService.servers;
protected readonly selectedServer = this.chatService.selectedServer;
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 onSelectedServerChange(server: string) {
this.chatService.selectServer(server);
}
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');
});
}
}
}
@@ -0,0 +1,13 @@
<div class="full-width"
[style.background-color]="getServerColor(selectedServer())">
<span class="server" [style.color]="getServerTextColor(selectedServer())">
{{ selectedServer() }}
</span>
<div class="notification-icon">
@if (notificationEnabled()) {
<svg-notification-enabled (click)="disableNotifications()"/>
} @else {
<svg-notification-disabled (click)="enableNotifications()"/>
}
</div>
</div>
@@ -0,0 +1,24 @@
.full-width {
display: flex;
align-items: center;
padding-top: 15px;
padding-bottom: 15px;
border-radius: 8px;
margin-bottom: 10px;
}
.server {
font-size: 20px;
font-family: minecraft-text, 'opensans-bold', sans-serif;
letter-spacing: 1px;
font-weight: bold;
padding-left: 10px;
}
.notification-icon {
cursor: pointer;
width: 20px;
height: 20px;
margin-left: auto;
margin-right: 10px;
}
@@ -0,0 +1,36 @@
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';
@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);
public readonly selectedServer = input.required<string>();
protected readonly notificationEnabled = computed(() => this.notificationService.isNotificationEnabled(this.selectedServer()));
protected readonly getServerColor = getServerColor;
protected readonly getServerTextColor = getServerTextColor;
public enableNotifications() {
this.notificationService.requestPermission().then(
() => {
this.notificationService.enableNotifications(this.selectedServer());
}
);
}
public disableNotifications() {
this.notificationService.disableNotifications(this.selectedServer());
}
}
@@ -0,0 +1,10 @@
<div class="message-container">
@for (message of messages(); track message.timestamp) {
<p class="message">
<span class="date-time" [class.blocked]="!message.notBlocked">
[{{ message.timestamp * 1000 | date: 'mediumTime' }}]
</span>
<mini-message [node]="message.messageJson"></mini-message>
</p>
}
</div>
@@ -0,0 +1,43 @@
:host {
display: block;
height: 100%;
}
.message-container {
display: flex;
flex-direction: column-reverse;
height: 100%;
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;
}
@@ -0,0 +1,17 @@
import {Component, 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';
@Component({
selector: 'app-chat-box',
imports: [
MiniMessageComponent,
DatePipe
],
templateUrl: './chat-box.component.html',
styleUrl: './chat-box.component.scss'
})
export class ChatBoxComponent {
readonly messages = input.required<ChatMessage[]>();
}
@@ -0,0 +1,31 @@
@if (node) {
<ng-container>
<a
*ngIf="isUrlLink; else plain"
[ngStyle]="ngStyle"
[mmObfuscated]="style.obfuscated"
[attr.title]="hoverTitle"
[attr.href]="node.clickEvent?.value"
[attr.data-insertion]="node.insertion || null"
target="_blank"
rel="noopener noreferrer"
class="mm-node mm-link"
>{{ text }}</a>
<ng-template #plain>
<span
[ngStyle]="ngStyle"
[mmObfuscated]="style.obfuscated"
[attr.title]="hoverTitle"
[attr.data-insertion]="node.insertion || null"
[class.mm-clickable]="hasNonUrlClick"
class="mm-node"
(click)="onClick()"
>{{ text }}</span
>
</ng-template>
@for (child of node.extra; track child) {
<mini-message [node]="child" [parentStyle]="style"></mini-message>
}
</ng-container>
}
@@ -0,0 +1,12 @@
.mm-node {
white-space: pre-wrap;
}
.mm-clickable {
cursor: pointer;
}
.mm-link {
text-decoration: inherit;
cursor: pointer;
}
@@ -0,0 +1,67 @@
import {CommonModule} from '@angular/common';
import {Component, Input, Optional} from '@angular/core';
import {MiniMessageComponent as MiniMessageNode, ResolvedStyle} from '../../mini-message/mini-message.types';
import {hoverEventToTitle, mergeStyle, ownText, styleToNgStyle} from '../../mini-message/mini-message.util';
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]="myComponentJson"></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!: MiniMessageNode;
@Input() parentStyle?: ResolvedStyle;
constructor(@Optional() private readonly interaction: MiniMessageInteractionService | null) {
}
get style(): ResolvedStyle {
return mergeStyle(this.parentStyle, this.node);
}
get ngStyle(): Record<string, string> {
return styleToNgStyle(this.style);
}
get text(): string {
return ownText(this.node);
}
get hoverTitle(): string | null {
return hoverEventToTitle(this.node.hoverEvent);
}
get isUrlLink(): boolean {
return this.node.clickEvent?.action === 'open_url';
}
get hasNonUrlClick(): boolean {
return !!this.node.clickEvent && this.node.clickEvent.action !== 'open_url';
}
onClick(): void {
const click = this.node.clickEvent;
if (!click) return;
if (click.action === 'copy_to_clipboard') {
navigator.clipboard?.writeText(click.value);
}
this.interaction?.emitClick({action: click.action, value: click.value});
}
}
@@ -0,0 +1,13 @@
<div class="server-list">
<p>Servers</p>
@for (server of servers(); track server.server) {
<div class="server" (click)="selectedServerChange.emit(server.server)">
<span class="server-name">{{ server.server }}</span>
@if (server.unreadMessages > 0) {
<div class="notification">
<svg-circle></svg-circle>
</div>
}
</div>
}
</div>
@@ -0,0 +1,29 @@
.server-list {
height: 100%;
background-color: #172133;
.server {
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;
}
.server-name {
color: white;
font-weight: bold;
}
.notification {
position: absolute;
right: 15px;
color: #3b82f6;
}
}
}
@@ -0,0 +1,27 @@
import {Component, effect, input, output} from '@angular/core';
import {ChatServer} from '@pages/altitude/chat/objects/chat-server.object';
import {CircleSvgComponent} from '@pages/altitude/chat/svg/circle.svg';
@Component({
selector: 'app-server-list',
imports: [
CircleSvgComponent
],
templateUrl: './server-list.component.html',
styleUrl: './server-list.component.scss'
})
export class ServerListComponent {
public readonly servers = input.required<ChatServer[]>();
public readonly selectedServer = input.required<string>();
public readonly selectedServerChange = output<string>();
constructor() {
effect(() => {
const chatServers = this.servers();
if (this.selectedServer() === '' && chatServers.length > 0) {
this.selectedServerChange.emit(chatServers[0].server);
}
});
}
}
@@ -0,0 +1,57 @@
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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;
}
@@ -0,0 +1,19 @@
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);
}
}
@@ -0,0 +1,25 @@
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));
}
}
@@ -0,0 +1,90 @@
/**
* 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 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;
}
@@ -0,0 +1,134 @@
import { MiniMessageComponent, MiniMessageHoverEvent, 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;
}
@@ -0,0 +1,64 @@
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}]`));
}
@@ -0,0 +1,65 @@
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;
}
}
}
@@ -0,0 +1,11 @@
import {MiniMessageComponent} from '@pages/altitude/chat/mini-message/mini-message.types';
export interface ChatMessage {
uuid: string;
timestamp: number;
server: string;
//TODO [Stijn] [2026-07-18]: Handle channel types
//channel: Channel;
messageJson: MiniMessageComponent;
notBlocked: boolean;
}
@@ -0,0 +1,4 @@
export interface ChatServer {
server: string;
unreadMessages: number;
}
@@ -0,0 +1,5 @@
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
export interface RawChatMessage extends Omit<ChatMessage, 'messageJson'> {
messageJson: string;
}
@@ -0,0 +1,110 @@
import {Injectable, signal} from '@angular/core';
@Injectable({providedIn: 'root'})
export class NotificationService {
private readonly notificationSound = new Audio('/public/sounds/notification.mp3');
private readonly notificationStorageKey = 'chat-notifications-enabled';
private readonly _notificationEnabled = signal<Record<string, boolean>>(
this.getStoredNotificationSettings()
);
public readonly notificationEnabled = this._notificationEnabled.asReadonly();
async requestPermission(): Promise<boolean> {
if (!('Notification' in window)) {
console.log('This browser does not support notifications');
return false;
}
if (Notification.permission === 'granted') {
return true;
}
if (Notification.permission === 'denied') {
console.log('Notification permission was denied');
return false;
}
const permission = await Notification.requestPermission();
if (permission === 'granted') {
this.enableSound()
return true
}
return false;
}
notify(server: string, title: string, body: string | null) {
if (!this.isNotificationEnabled(server)) {
return;
}
if (Notification.permission === 'granted') {
if (body === null) {
new Notification(title);
} else {
new Notification(title, {body});
}
this.playSound();
}
}
private enableSound() {
this.notificationSound.muted = true;
this.notificationSound.play()
.then(() => {
this.notificationSound.pause();
this.notificationSound.currentTime = 0;
this.notificationSound.muted = false;
})
.catch((error) => {
console.log('Could not unlock notification sound:', error);
this.notificationSound.muted = false;
});
}
private playSound() {
this.notificationSound.currentTime = 0;
this.notificationSound.play().catch((error) => {
console.log('Could not play notification sound:', error);
});
}
public isNotificationEnabled(server: string): boolean {
return this._notificationEnabled()[server];
}
enableNotifications(server: string) {
this.setNotificationEnabled(server, true);
}
disableNotifications(server: string) {
this.setNotificationEnabled(server, false);
}
private setNotificationEnabled(server: string, enabled: boolean) {
const notificationSettings = {
...this._notificationEnabled(),
[server]: enabled
};
this._notificationEnabled.set(notificationSettings);
localStorage.setItem(this.notificationStorageKey, JSON.stringify(notificationSettings));
}
private getStoredNotificationSettings(): Record<string, boolean> {
const storedSettings = localStorage.getItem(this.notificationStorageKey);
if (storedSettings === null) {
return {};
}
if (storedSettings === 'true' || storedSettings === 'false') {
return {};
}
try {
return JSON.parse(storedSettings) as Record<string, boolean>;
} catch {
return {};
}
}
}
@@ -0,0 +1,123 @@
import {computed, inject, Injectable, OnDestroy, signal} from '@angular/core';
import {AuthService} from '@services/auth.service';
import {EventSourcePolyfill} from 'event-source-polyfill';
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
import {RawChatMessage} from '@pages/altitude/chat/objects/raw-chat-message.object';
import {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util';
import {ChatServer} from '@pages/altitude/chat/objects/chat-server.object';
import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
export interface ChatEvent {
type: string;
data: any;
}
interface SsePayloadEvent {
data: string;
}
@Injectable({providedIn: 'root'})
export class ChatService implements OnDestroy {
private eventSource?: EventSourcePolyfill;
private readonly notificationService: NotificationService = inject(NotificationService);
private readonly authService: AuthService = inject(AuthService)
private readonly _messages = signal<ChatMessage[]>([])
public readonly messages = computed(() => this._messages().filter((message) => message.server === this._selectedServer()).sort((a, b) => b.timestamp - a.timestamp));
private readonly _servers = signal<ChatServer[]>([])
public readonly servers = computed(() => this._servers().sort((a, b) => a.server.localeCompare(b.server)));
private readonly _selectedServer = signal<string>('');
public readonly selectedServer = this._selectedServer.asReadonly()
connect() {
if (this.eventSource) {
return; // already connected
}
const jwt = this.authService.getJwt();
if (!jwt) {
//TODO [Stijn] [2026-07-18]: Error when no JWT available (log in?)
return;
}
const source = new EventSourcePolyfill('/api/chat/read/subscribe', {
headers: {
Authorization: `Bearer ${jwt}`
},
heartbeatTimeout: 60000
});
this.eventSource = source;
this.on(source, 'connected', (event) => {
this.processChatMessages(event);
});
this.on(source, 'chat', (event) => {
const chatMessages = this.processChatMessages(event);
chatMessages.forEach((message) => {
const find = this.servers().find(server => message.server === server.server && message.server !== this._selectedServer());
if (find) {
find.unreadMessages++;
}
})
const messagesByServer = chatMessages.reduce<Record<string, number>>((acc, message) => {
acc[message.server] = (acc[message.server] ?? 0) + 1;
return acc;
}, {});
Object.entries(messagesByServer).forEach(([server, messageCount]) => {
this.notificationService.notify(server, `New messages`, `(${messageCount}) new messages in ${server}`);
});
});
source.onerror = (err) => {
console.error('SSE error, polyfill will auto-reconnect:', err);
};
}
private processChatMessages(event: SsePayloadEvent): ChatMessage[] {
const raw = JSON.parse(event.data) as RawChatMessage[];
const messages: ChatMessage[] = raw.map((m) => ({
...m,
messageJson: normalizeComponent(JSON.parse(m.messageJson)),
}));
this._messages.update((old) => [...old, ...messages]);
//TODO [Stijn] [2026-07-19]: Handle servers by getting them from backend
messages.map((message) => message.server).forEach((server) => {
const found = this._servers().find(existing => existing.server === server);
if (!found) {
this._servers.update((old) => [...old, {server: server, unreadMessages: 0}]);
}
})
return messages;
}
public selectServer(server: string) {
this._selectedServer.set(server);
this._servers.update((old) =>
old.map((chatServer) =>
chatServer.server === server
? {...chatServer, unreadMessages: 0}
: chatServer
)
);
}
// single, deliberate escape hatch from the broken DOM/polyfill type overlap —
// everything else in this file stays fully typed
private on(source: EventSourcePolyfill, eventName: string, handler: (event: SsePayloadEvent) => void): void {
(source as unknown as { addEventListener: (type: string, listener: (event: SsePayloadEvent) => void) => void })
.addEventListener(eventName, handler);
}
disconnect(): void {
this.eventSource?.close();
this.eventSource = undefined;
}
ngOnDestroy(): void {
this.disconnect();
}
}
@@ -0,0 +1,19 @@
import {Component} from '@angular/core';
@Component({
selector: 'svg-circle',
imports: [],
template: `
<svg
width="10px"
height="10px" viewBox="0 0 6 6" id="meteor-icon-kit__solid-circle-xxxs" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M3 6C1.3431 6 0 4.6569 0 3C0 1.3431 1.3431 0 3 0C4.6569 0 6 1.3431 6 3C6 4.6569 4.6569 6 3 6z"
fill="currentColor"/>
</svg>
`
})
export class CircleSvgComponent {
}
@@ -0,0 +1,16 @@
import {Component} from '@angular/core';
@Component({
selector: 'svg-notification-disabled',
imports: [],
template: `
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 3L21 21M9.37747 3.56325C10.1871 3.19604 11.0827 3 12 3C13.5913 3 15.1174 3.59 16.2426 4.6402C17.3679 5.69041 18 7.11479 18 8.6C18 10.3566 18.2892 11.7759 18.712 12.9122M17 17H15M6.45339 6.46451C6.15686 7.13542 6 7.86016 6 8.6C6 11.2862 5.3238 13.1835 4.52745 14.4866C3.75616 15.7486 3.37051 16.3797 3.38485 16.5436C3.40095 16.7277 3.43729 16.7925 3.58603 16.9023C3.71841 17 4.34762 17 5.60605 17H9M9 17V18C9 19.6569 10.3431 21 12 21C13.6569 21 15 19.6569 15 18V17M9 17H15"
stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`
})
export class NotificationDisabledSvgComponent {
}
@@ -0,0 +1,16 @@
import {Component} from '@angular/core';
@Component({
selector: 'svg-notification-enabled',
imports: [],
template: `
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.00195 17H5.60636C4.34793 17 3.71872 17 3.58633 16.9023C3.4376 16.7925 3.40126 16.7277 3.38515 16.5436C3.37082 16.3797 3.75646 15.7486 4.52776 14.4866C5.32411 13.1835 6.00031 11.2862 6.00031 8.6C6.00031 7.11479 6.63245 5.69041 7.75766 4.6402C8.88288 3.59 10.409 3 12.0003 3C13.5916 3 15.1177 3.59 16.2429 4.6402C17.3682 5.69041 18.0003 7.11479 18.0003 8.6C18.0003 11.2862 18.6765 13.1835 19.4729 14.4866C20.2441 15.7486 20.6298 16.3797 20.6155 16.5436C20.5994 16.7277 20.563 16.7925 20.4143 16.9023C20.2819 17 19.6527 17 18.3943 17H15.0003M9.00195 17L9.00031 18C9.00031 19.6569 10.3435 21 12.0003 21C13.6572 21 15.0003 19.6569 15.0003 18V17M9.00195 17H15.0003"
stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`
})
export class NotificationEnabledSvgComponent {
}
@@ -0,0 +1,21 @@
export function getServerColor(server: string) {
if (server === 'bayou') {
return "#0290e8";
} else if (server === 'lobby') {
return "white";
} else if (server === 'creative') {
return "#FFA500";
}
return "white";
}
export function getServerTextColor(server: string) {
if (server === 'bayou') {
return "white";
} else if (server === 'lobby') {
return "black";
} else if (server === 'creative') {
return "black";
}
return "white";
}
@@ -9,34 +9,90 @@
<main> <main>
<section class="darkmodeSection"> <section class="darkmodeSection">
<div class="customContainer"> <div class="container teamContainer">
<h2>Current Nitro Boosters</h2> <h2 class="sectionTitle">Current Nitro Boosters</h2>
@for (member of getTeamMembers('discord') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Nitro booster</p>
</div>
}
</div> </div>
</section> </section>
<section id="social" class="darkmodeSectionThree"> <section class="darkmodeSectionThree">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;"> <div class="container teamContainer">
<h2 class="sectionTitle">Social Media</h2> <h2 class="sectionTitle">Social Media</h2>
@for (member of getTeamMembers('socialmedia') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Social media</p>
</div>
}
</div> </div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;"> <div style="display: flex; justify-content: center; padding-bottom: 30px;">
<p style="text-align: center;">We're currently not looking for more people to help manage our socials.</p> <p style="text-align: center;">We're currently not looking for more people to help manage our socials.</p>
</div> </div>
</section> </section>
<section id="crateTeam" class="darkmodeSection"> <section class="darkmodeSection">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;"> <div class="container teamContainer">
<h2 class="sectionTitle">Crate Team</h2> <h2 class="sectionTitle">Developers</h2>
@for (member of getTeamMembers('developer') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Developer</p>
</div>
}
</div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
<p style="text-align: center;">If you want to be a developer please reach out to .teri on Discord.</p>
</div> </div>
</section> </section>
<section class="darkmodeSectionThree"> <section class="darkmodeSectionThree">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;"> <div class="container teamContainer">
<h2 class="sectionTitle">Crate Team</h2>
@for (member of getTeamMembers('crate') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Crate team</p>
</div>
}
</div>
</section>
<section class="darkmodeSection">
<div class="container teamContainer">
<h2 class="sectionTitle">Event Leaders</h2> <h2 class="sectionTitle">Event Leaders</h2>
@for (member of getTeamMembers('eventleader') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Event leaders</p>
</div>
}
</div> </div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;"> <div style="display: flex; justify-content: center; padding-bottom: 30px;">
<p style="text-align: center;">We're currently not looking for more Event Leaders.</p> <p style="text-align: center;">We're currently not looking for more Event Leaders.</p>
</div> </div>
</section> </section>
<section class="darkmodeSection"> <section class="darkmodeSectionThree">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;"> <div class="container teamContainer">
<h2 class="sectionTitle">Event Team</h2> <h2 class="sectionTitle">Event Team</h2>
@for (member of getTeamMembers('eventteam') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Event team</p>
</div>
}
</div> </div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;"> <div style="display: flex; justify-content: center; padding-bottom: 30px;">
<div style="flex-direction: column;"> <div style="flex-direction: column;">
@@ -46,16 +102,33 @@
</div> </div>
</div> </div>
</section> </section>
<section class="darkmodeSectionThree"> <section class="darkmodeSection">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;"> <div class="container teamContainer">
<h2 class="sectionTitle">YouTubers & Streamers</h2> <h2 class="sectionTitle">YouTubers & Streamers</h2>
@for (member of getTeamMembers('youtube') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Youtuber</p>
</div>
}
@for (member of getTeamMembers('twitch') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Streamer</p>
</div>
}
</div> </div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;"> <div style="display: flex; justify-content: center; padding-bottom: 30px;">
<div style="flex-direction: column;"> <div style="flex-direction: column;">
<p style="text-align: center;"><a style="cursor: pointer;" id="reqButton">Show Requirements...</a></p> <p style="text-align: center;"><a style="cursor: pointer;" (click)="toggleSection('yt-stream-req')"
id="reqButton">{{ getTextForSection('yt-stream-req') }}</a></p>
</div> </div>
</div> </div>
<div id="req" class="hide" style="display: flex; justify-content: center; padding-bottom: 30px;"> <div [hidden]="!isToggled('yt-stream-req')" [class.requirementSection]="isToggled('yt-stream-req')">
<div style="flex-direction: column; justify-content: center; max-width: 800px;"> <div style="flex-direction: column; justify-content: center; max-width: 800px;">
<p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Requirements:</span> <p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Requirements:</span>
</p> </p>
@@ -1,12 +1,32 @@
.customContainer { .sectionTitle {
width: 80%; flex: 0 0 100%;
max-width: 1020px; text-align: center;
margin: auto; padding-bottom: 20px;
padding: 80px 0; font-size: 2em;
}
.member {
width: 33%;
min-width: 250px;
padding-bottom: 50px;
text-align: center; text-align: center;
} }
.hide { .member img {
display: none !important; padding-bottom: 15px;
} }
.member p {
font-family: 'opensans-bold', sans-serif;
}
.teamContainer {
padding: 50px 0 0 0;
justify-content: center;
}
.requirementSection {
display: flex;
justify-content: center;
padding-bottom: 30px;
}
@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CommunityComponent } from './community.component';
describe('CommunityComponent', () => {
let component: CommunityComponent;
let fixture: ComponentFixture<CommunityComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CommunityComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CommunityComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

Some files were not shown because too many files have changed in this diff Show More