385 Commits
Author SHA1 Message Date
stijn 4623fbcc82 Update ALTITUDE_VERSION to 26.2 in constant.ts 2026-09-18 20:42:20 +02:00
stijn 610ef2f327 Add warning logs for missing emitters and failed event delivery in EventPublisher 2026-08-24 21:44:50 +02:00
stijn bd6174dd23 Add logging for server subscriptions in ServerMessageService 2026-08-22 22:40:18 +02:00
stijn 8a61d35e63 Add ServerMessageService for broadcasting player state updates, implement PlayerListState model, and update ChatService to synchronize active player states across servers. 2026-08-22 19:27:02 +02:00
stijn 2a65f98493 Add ServerMessageService for broadcasting player state updates, implement PlayerListState model, and update ChatService to synchronize active player states across servers. 2026-08-22 18:22:12 +02:00
stijn 423d9c9043 Add admin chat endpoint and request schemas for private messages, party messages, and punishments 2026-08-09 15:41:34 +02:00
stijn 7f94569575 Add admin chat endpoint and request schemas for private messages, party messages, and punishments 2026-08-09 14:53:25 +02:00
stijn 1a0bc902ac Refactor user name display logic: integrate MiniMessageComponent in UserListComponent and transition to ProcessedMiniMessageNode. Add server state caching in ChatService with eager state dispatch on subscription. 2026-08-09 02:04:11 +02:00
stijn 8c02543bca Add user list toggle with responsive visibility in ChatService and ChatInfoComponent, implement UserListComponent, and update styles for user list and toggles. 2026-08-09 00:59:44 +02:00
stijn 2705cf14ab Add ServerState support in ChatService with SSE listener and associated data model 2026-08-09 00:48:17 +02:00
stijn 65d0118e3d Implement server state handling in ChatController and ChatService, add ServerStateMapper utility, and create/update server state unit tests. 2026-08-09 00:41:36 +02:00
stijn b61b117f41 Enforce 256-character limit on chat messages with validation in ChatBoxComponent and schema updates. 2026-08-08 23:45:42 +02:00
stijn bdda71229d Update ChatService to initialize totalMessages to 1; adjust chat-info styles to use bold font for message count. 2026-08-08 23:41:27 +02:00
stijn 49b85bb695 Handle SERVER channel type in name-format.service to capitalize channel names. 2026-08-08 22:59:11 +02:00
stijn ce7c68511b Validate sender UUID in sendServerMessage to prevent spoofing and enhance security. 2026-08-02 18:43:12 +02:00
stijn 065db06e16 Add chat input functionality in ChatBoxComponent and backend support for sending server messages 2026-08-02 18:30:33 +02:00
stijn 8533cfa365 Remove unused test message logic and JSON serialization method from ServerMessageService. 2026-08-02 17:38:47 +02:00
stijn d3389159b6 Improve subscribe endpoint in CommandsToServerController: add error logging for missing or invalid tokens 2026-08-02 17:30:28 +02:00
stijn 8849612138 Update subscribe endpoint in CommandsToServerController to use X-Altitude-Token header instead of AuthenticationPrincipal; fix invalid token handling; add return in ServerMessageService test message logic. 2026-08-02 17:25:50 +02:00
stijn 9757ce2ffa Inject EventPublisher dependency into ChatServiceTest setup for enhanced test isolation and mocking. 2026-08-02 17:21:17 +02:00
stijn 4f67228c39 Update CommandsToServerController route and adjust security config to permit access to the new route 2026-08-02 17:16:59 +02:00
stijn b71a2f19b8 Refactor EventPublisher and ChatService: replace UUID-based keys with generic unique string keys, add session cleanup callbacks, and update related logic for better extensibility and reusability. 2026-08-02 17:06:53 +02:00
stijn f7edde65d6 Refactor ChatMessage model and components: rename messageJson to miniMessageNode for clarity and consistency. 2026-08-01 23:36:40 +02:00
stijn 90eb40caa7 Defer rendering of mini-message components for optimized loading using viewport and idle prefetch placeholders. 2026-08-01 23:36:04 +02:00
stijn c8b5a01c21 Refactor mini-message system: add precomputed styles for optimization, update models and components to use preprocessed nodes, and display total message counts in ChatInfo UI. 2026-08-01 23:33:34 +02:00
stijn 819739ff60 Add legacy color code support in NameFormatService and implement unit tests for colored name parsing. 2026-08-01 22:52:50 +02:00
stijn 5be3072b26 Extract display name logic to NameFormatService, refactor colored name parsing, and improve channel list scrolling. 2026-08-01 22:42:37 +02:00
stijn 5987ee4133 Remove redundant test assertion for sender visibility in ChatServiceTest 2026-08-01 22:24:27 +02:00
stijn 6a52d27c09 Improve gradient-colored name parsing in ChannelListComponent and add scroll support in the channel list UI. 2026-08-01 22:20:39 +02:00
stijn 73ea715b01 Add support for "SPY" channel type: updated models, ChatService, and ChannelListComponent to handle SPY channels and their name mappings. 2026-08-01 22:01:59 +02:00
stijn ed942d5ec5 Simplify MSG case handling in ChatService by directly comparing receiver UUID. 2026-08-01 21:52:29 +02:00
stijn a9d788b319 Group channels by type with toggleable collapsed view in ChannelListComponent: added grouping logic, collapsed state management, updated UI with group titles, and adjusted styles for indentation. 2026-08-01 21:46:02 +02:00
stijn d1acf39a22 Add gradient-colored display names in ChannelListComponent: implemented name parsing with gradient support, refactored UI logic to dynamically render colored name segments, and improved message handling in ChatService with null channel filtering. 2026-08-01 21:16:37 +02:00
stijn 8f9aea2e0f Refactor ChatService and ChannelListComponent: integrate ChatService into ChannelListComponent, rename partieId to partyId, update party and DM name mappings in UI, and remove debug log in AuthService. 2026-08-01 20:47:34 +02:00
stijn 8ecfb99b27 Add ChatInfoService for retrieving player and party names: integrated backend APIs, updated chat service with caching logic for names, and extended OpenAPI documentation. 2026-08-01 20:22:38 +02:00
stijn 79a62e9ce9 Add party name retrieval: implemented PartyMapper for database queries, updated ChatService with new getPartyName method, and extended /chat/party/{id}/name API endpoint. 2026-08-01 20:03:49 +02:00
stijn 8931bdc30d Add nickname retrieval support: implemented NicknameMapper for database queries, updated ChatService to return player names with optional nicknames, and extended /chat/player/{uuid}/name API endpoint. 2026-08-01 19:57:55 +02:00
stijn f9f05dcc88 Replace servers with channels in chat functionality: refactored UI components, service logic, and message model to support channels (SERVER, DM, PARTY, GAC). 2026-08-01 19:10:33 +02:00
stijn 0d6a4ee8dd Link party IDs to users in chat: added getPartyId query, updated EventUser with partyId field, and adjusted message filtering logic in ChatService. 2026-08-01 18:47:06 +02:00
stijn 3100ac21c3 Add ChatService test suite to verify message filtering and delivery to appropriate users. 2026-08-01 18:26:30 +02:00
stijn 3462d82716 Track chat session lifecycles: added session start/end times, database schema, and persistence logic. 2026-08-01 17:40:48 +02:00
stijn b1267068a5 Add type, channel, and receiver fields to chat model and filter messages by PUBLIC channel type in ChatService. 2026-07-19 22:54:35 +02:00
stijn 1c2478e8d0 Add type, channel, and receiver fields to chat model and database handling. 2026-07-19 22:52:14 +02:00
stijn 7750bf6439 Expand /api/chat/read/** access to include MOD permission. 2026-07-19 20:42:00 +02:00
stijn a390ef98e6 Expand moderator access to chat by including MOD permission in access checks. 2026-07-19 20:18:50 +02:00
stijn cd18fd4b96 Expand moderator roles by adding SCOPE_mod permission and updating authorization logic. 2026-07-19 20:17:01 +02:00
stijn 9e5148fa8c Ensure unique timestamps for chat messages to prevent collisions and fix date formatting in chat-box UI. 2026-07-19 19:20:38 +02:00
stijn 564582a6f9 Simplify notification logic by removing permission handling, consolidating methods, and refining server-based notification management. 2026-07-19 19:08:12 +02:00
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
stijn 8bfcdb6ccc Replace edit button with a Material icon, adjust styles, and add MatIconModule to component imports. 2025-10-24 21:28:20 +02:00
stijn 64ea68ab39 Refactor AuthenticatedUuid to singleton service and replace static calls across the codebase. Add JWT authority converters, improve punishment expiry handling, and enhance frontend dialog functionality for editing punishments. Extend CORS allowed methods and origins. 2025-10-24 21:10:34 +02:00
stijn f117cb2477 Remove debug console.log statements from auth.service.ts. 2025-10-24 19:52:26 +02:00
stijn d84d0c7fef Add conditional button styling and logic to indicate vote availability based on last vote timestamp. 2025-10-24 19:50:48 +02:00
stijn 00bf7caec2 Add vote statistics feature and improve vote page functionality 2025-10-24 19:39:08 +02:00
stijn 41dab473b0 Add admin endpoints for editing and removing punishments and implement frontend dialog for punishment management 2025-10-23 23:52:52 +02:00
stijn b71ea7da8b Limit returned usernames to 1 2025-10-21 22:48:21 +02:00
stijn a55806e5dd make scheduled methoded protected, fix connection 2025-10-21 22:22:43 +02:00
stijn 7e25cc583c Enable scheduling so code cache gets cleared 2025-10-21 22:17:08 +02:00
stijn 894dfac0c6 Validate weekly playtime as a whole number in staff application form. 2025-10-19 02:39:56 +02:00
stijn 300d33da7d Prevent duplicate staff application submissions by disabling the submit button during processing. 2025-10-18 23:10:56 +02:00
stijn 6f6801c728 Include applicant's username in staff application emails and Discord notifications. 2025-10-18 23:07:03 +02:00
stijn f8157e997a Update staff application close date to 2025-10-26 to match open period 2025-10-18 02:53:32 +02:00
stijn 74e8697fef Enhance "Staff applications closed" message with improved styling and structure. 2025-10-18 02:49:51 +02:00
stijn 8ad87da47e Update player count text to include context on home page 2025-10-18 02:46:31 +02:00
stijn 29a28e712e Add player count display with periodic updates to home page 2025-10-18 02:43:23 +02:00
stijn 6ad3b5221a Update Grove map button label to "Bayou" 2025-10-18 02:31:27 +02:00
stijn 1b697fcaa3 Add redirections for appeal and staff application forms paths 2025-10-17 22:05:00 +02:00
stijn ed9d41cdc6 Add conditional display logic to staff application form based on open/close status 2025-10-17 22:00:23 +02:00
stijn 5eaeb3552a Add API endpoint to check staff application availability and enforce open/close periods 2025-10-17 21:42:32 +02:00
stijn 8e9e267fb0 Update grove map button link to point bayou map 2025-10-17 20:11:23 +02:00
stijn 6d8f73201f Restrict "Particles" dropdown link visibility based on HEAD_MOD permission claim. 2025-10-12 22:05:20 +02:00
stijn 91e5a2a9a0 Refactor form validation and add user feedback with snackbar notifications in staff application form. 2025-10-12 21:57:40 +02:00
stijn 0005b3b6d4 Set login dialog width to 400px in auth guard. 2025-10-12 21:42:58 +02:00
stijn b3999b3389 Prompt login dialog when user is unauthenticated during auth guard check. 2025-10-12 21:40:54 +02:00
stijn 5a4df2572d Revert "Prompt login dialog when no JWT is found during authentication check."
This reverts commit e3fd0944df.
2025-10-12 21:37:54 +02:00
stijn e697f7ca90 Revert "Add navigation to current URL after login dialog completion"
This reverts commit 3da46c203b.
2025-10-12 21:37:53 +02:00
stijn 3da46c203b Add navigation to current URL after login dialog completion 2025-10-12 21:34:59 +02:00
stijn e3fd0944df Prompt login dialog when no JWT is found during authentication check. 2025-10-12 21:33:51 +02:00
stijn cd34cd93ad Update checkbox styles to use theme font color. 2025-10-12 21:29:04 +02:00
stijn a9294d1115 Update staff application email subject formatting 2025-10-12 21:24:46 +02:00
stijn 745dab4d80 Update query in SettingsMapper to use internal_name instead of name for database identification. 2025-10-12 21:16:16 +02:00
stijn dc65b19a8f Add error handling and logging improvements for database settings loading process. 2025-10-12 21:04:59 +02:00
stijn 01dab905d4 Improve staff application flow with error handling updates, new email address, and code formatting adjustments. 2025-09-27 21:13:04 +02:00
stijn 311d77fcb2 Enhance staff application flow with email verification checks, refined error handling, and improved user feedback in frontend and backend. 2025-09-27 20:00:44 +02:00
stijn cdbf862ecf Add staff application email and Discord notification integration 2025-09-24 23:33:36 +02:00
stijn 643b15f2e0 Add staff application support with database integration and submission flow 2025-09-24 23:12:09 +02:00
stijn f886609a0e Add staff application feature with API integration and frontend form implementation 2025-09-24 22:26:17 +02:00
stijn 2a0f38aa28 Configure proxy for API requests and remove hardcoded apiUrl from environment files. 2025-09-24 21:47:55 +02:00
stijn 4878ad9f0d Configure proxy for API requests and remove hardcoded apiUrl from environment files. 2025-09-24 21:44:41 +02:00
Peter 80cb2d0ad1 Modified styling for appeal template 2025-08-30 15:41:26 +02:00
Peter 53f67c0b67 Updated version and voting requirements 2025-08-30 14:16:07 +02:00
stijn 1f1f1793e3 Remove redundant comment in AppealDiscord and enhance state handling in SentComponent. 2025-08-24 03:15:42 +02:00
stijn 4962d16abd Add EmailVerificationMapper initialization in web database setup. 2025-08-24 02:49:09 +02:00
stijn cad574b8fb Set default email value programmatically in appeal form and remove inline default logic. 2025-08-24 02:43:49 +02:00
stijn c75f0cdb15 Mark appeals as sent when successfully submitted and processed, ensuring accurate tracking and status updates. 2025-08-24 02:39:47 +02:00
stijn fe545972e3 Fix typo in email validation message for improved clarity in appeal form UI. 2025-08-24 00:49:51 +02:00
stijn eab1c9322b Add embed message support to Discord bot and update appeal flow to use embeds for Discord notifications 2025-08-24 00:43:58 +02:00
stijn ffddffa8dc Add Discord bot support for sending appeals to specified channels and integrate with appeal flow 2025-08-23 23:51:45 +02:00
stijn 0b4c1ccebf Format createdAt in appeal emails to UTC timezone and improve readability. 2025-08-23 23:27:45 +02:00
stijn 2e89fcec66 Add initial Discord bot integration with JDA setup and environment token configuration 2025-08-23 23:17:51 +02:00
stijn 42b11eecf1 Add email re-validation handling with UI feedback and backend validation to prevent duplicate email verification attempts 2025-08-23 22:59:22 +02:00
stijn d1da1296bb Add SentComponent for form submission confirmation and integrate with email verification flow 2025-08-23 22:42:18 +02:00
stijn 523bf3d43f Refactor SentComponent to VerifyMailDialogComponent for improved clarity and usability in email verification flow. 2025-08-23 22:34:08 +02:00
stijn 4ccce7e190 Improve email verification flow by adding verified email pre-fill, validation handling, and dialog-based verification support. 2025-08-23 22:32:44 +02:00
stijn 641083732d Add email verification functionality, including backend support, email handling, and user interface integration. 2025-08-23 21:46:10 +02:00
stijn da17cf9696 Remove defaultAuthStatus from all environment configurations. 2025-08-23 20:13:59 +02:00
stijn de1876c90c Add historyType and historyId to Appeal, update database schema, API, and email templates to include punishment details. 2025-08-16 23:40:20 +02:00
stijn c3f3b20807 Update mail configuration to use SSL with custom socket factory. 2025-08-16 23:01:22 +02:00
stijn 37fb49cda1 Inject SpringTemplateEngine into AppealMail and remove redundant field initialization. 2025-08-16 20:28:29 +02:00
stijn db642103ed Add email notification service for appeals using Spring Mail and Thymeleaf templates. 2025-08-16 20:23:35 +02:00
stijn f026f24263 Refactor createAppeal in AppealMapper to void return type, adjust AppealController to remove redundant UUID handling. Optimize AppealDataMapper to use UUID.randomUUID(). 2025-08-14 02:34:28 +02:00
stijn eaee31ab2b Change createAppeal method in AppealMapper to return UUID, adjust AppealController to construct Appeal with generated UUID. 2025-08-14 01:37:57 +02:00
stijn 24d7cfe913 Register UUIDTypeHandler in MyBatis configuration to handle UUID mapping in Connection.java. 2025-08-13 23:56:31 +02:00
stijn 770a2e0d14 Add Minecraft appeal functionality with database integration, UUID handling, and API response adjustments. 2025-08-13 23:54:20 +02:00
stijn 101794d8f2 Remove username property from appeal.yml schema in Discord ban appeal form. 2025-08-08 21:01:58 +02:00
stijn eb72ce14cc Integrate HistoryFormatService into AppealComponent to filter inactive history items. 2025-08-06 00:31:30 +02:00
stijn d1ba89acc8 Refactor KeyPairMapper to improve query readability by aliasing fields and reformatting SQL. 2025-08-05 23:55:15 +02:00
stijn d28b4a2b62 Refactor LoginController to use pattern matching for Jwt type check, simplify uuid extraction, and adjust debug logs. 2025-08-05 23:49:11 +02:00
stijn 56f4ccf40e Add session rollback handling in Connection.java, improve resource management, and refine debug logging in LoginController. 2025-08-05 23:46:47 +02:00
stijn d73f057596 Remove app.config.ts, integrate configuration directly in main.ts, and refactor providers to include authInterceptor. 2025-08-05 23:41:25 +02:00
stijn e825d83124 Integrate authInterceptor for JWT handling, update API schema with bearerAuth security, and refactor configuration to support HTTP interceptors. 2025-08-05 23:35:32 +02:00
stijn 238c5d9644 Refactor reloadUsername in auth.service to remove debug logs, simplify subscription handling, and add null check before invocation. 2025-08-05 23:29:37 +02:00
stijn 4222df87a3 Add debug logs to reloadUsername in auth.service for improved error and data tracking 2025-08-05 23:26:23 +02:00
stijn 16cc57d774 Update auth.service log message for clarity when saving user claims 2025-08-05 23:25:16 +02:00
stijn c536bfbf30 Add debug logs to checkAuthStatus for missing or expired JWT 2025-08-05 23:23:26 +02:00
stijn f67cb50f41 Refactor logging in LoginController, simplify auth.service token validation, and remove debug logs from AppealComponent. 2025-08-05 23:22:12 +02:00
stijn bdb38e5011 Add username retrieval functionality to LoginController using RecentNamesMapper query 2025-08-05 23:12:50 +02:00
stijn ae1e972438 Implement appeal form flow with dynamic pages, integrate punishment selection, and add username retrieval logic. Update API schema and enhance auth.service for username handling. 2025-08-05 23:11:38 +02:00
stijn 737b26a6c7 Update environment configs to include defaultAuthStatus property. 2025-08-05 21:05:38 +02:00
stijn 5013b9a204 Add pagination logic to AppealComponent and update layout structure. 2025-08-05 20:59:22 +02:00
stijn fcb64db137 Make appeal form centered and create landing page 2025-08-05 20:17:37 +02:00
stijn d2e064e2b4 Add priority attribute to Discord button image in footer for improved loading behavior. 2025-08-05 20:07:38 +02:00
stijn f50f2dc6c2 Update login form label to provide instructions for obtaining the code from the Minecraft server. 2025-08-03 01:27:49 +02:00
stijn c277306c2c Refactor FormsComponent and add AuthGuard for appeal route protection. Replace dynamic routing with static appeal route, restructure AppealComponent layout, and introduce responsive design adjustments. Update environment configuration for default auth status. 2025-08-03 00:09:27 +02:00
stijn 1f03a4bdc3 Implement AuthGuard for route protection, integrate authorization checks into particles route, and simplify HeaderComponent access logic. Remove redundant debug logging in auth.service.ts. 2025-08-02 22:27:37 +02:00
stijn 7f1c59d102 Rename authorizations to authorities in JWT interface and related method in auth.service.ts. Update debug logging accordingly. 2025-08-02 22:11:54 +02:00
stijn f968a64dd4 Add debug logging for user claims in auth.service.ts methods. 2025-08-02 22:08:56 +02:00
stijn c25364caf7 Add detailed debug logging in hasAccess method for clearer authorization checks. 2025-08-02 22:02:23 +02:00
stijn 15c3cc7f26 Remove redundant debug logging in auth.service.ts and add logging in hasAccess method for authorization checks. 2025-08-02 21:55:39 +02:00
stijn 2b96957876 Add debug logging for JWT claims extraction in auth.service.ts. 2025-08-02 21:49:41 +02:00
stijn b16fab26e7 Refactor hasAccess method in auth.service.ts to improve readability and optimize authorization checking logic. 2025-07-31 23:30:48 +02:00
stijn 28fd05a656 Update HeaderComponent HTML: adjust access check condition to use SCOPE_head_mod instead of HEAD_MOD. 2025-07-31 23:08:02 +02:00
stijn ff1b09be92 Add debug logging for resource handling in WebConfig. 2025-07-31 22:03:34 +02:00
stijn 8a839ac922 Refactor WebConfig and SecurityConfig to enhance routing with /api prefix, disable CSRF and anonymous access; update OpenAPI paths accordingly. Add HomeController for default route handling. 2025-07-31 21:32:55 +02:00
stijn 3f76a98409 Rename server.address to my-server.address across properties files and LoginController for consistency and clarity. 2025-07-30 00:18:22 +02:00
stijn 871615702b Update Node.js to v20.19.0 and npm to v10.2.3; refactor npmBuild task to use plugin-provided npmCommand and add nodeVersionCheck task for environment validation. 2025-07-30 00:08:00 +02:00
stijn 291c9df5c6 Upgrade Angular dependencies and ngx-cookie-service in package.json to latest versions. 2025-07-29 23:53:54 +02:00
stijn 4150324d75 Replace valueOf with fromValue in LoginController for permission claim mapping. 2025-07-29 23:17:32 +02:00
stijn 4267c782a7 Add debug logging for user loading, permissions processing, and token generation in LoginController. 2025-07-29 23:16:04 +02:00
stijn 343964eda8 Add server.address property and update issuer field in LoginController to use dynamic server address. Modify SQL query in PrivilegedUserMapper to simplify permissions retrieval. 2025-07-28 23:01:18 +02:00
stijn 1ce2088cae Update HeaderComponent HTML: change "Login" label to "Logout" for logout button. 2025-07-15 23:42:03 +02:00
stijn 0b952e07f7 Refactor HeaderComponent to organize inputs, inject dependencies, and improve HTML formatting. Add logout method for authentication management. 2025-07-15 21:51:03 +02:00
stijn c2b9a8a574 Replace deprecated Angular directives (*ngFor, *ngIf) with modern Angular template syntax. Remove unused CommonModule imports across components for optimization. Clean up excess spacing and formatting in HTML files. 2025-07-15 21:48:23 +02:00
stijn d3ef296784 Upgrade TypeScript to version ^5.8.3 in package.json. 2025-07-15 21:39:40 +02:00
Peter 5a792463cc Update responsive styles and class names for consistency
Refactored multiple components to improve responsive design, ensuring better usability on smaller screens. Standardized class names for clarity and consistency, and adjusted layouts and styles to enhance overall alignment and accessibility.
2025-07-14 22:49:22 +02:00
Peter 974d50d7cd Merge remote-tracking branch 'origin/master' 2025-07-06 21:33:27 +02:00
Peter 62f837914c Add new routes and improve theme and header responsiveness
Added routes for 'community', 'nicknames', and 'nickgenerator'. Enhanced theme switch positioning and responsive design for smaller screens. Moved header login button to a more consistent position and upgraded Angular Material to version 19.2.19 for better compatibility.
2025-07-06 21:33:14 +02:00
stijn ace969ba3b Remove Blob handling logic from AuthService.login and simplify JWT processing. 2025-07-06 21:00:52 +02:00
stijn 2fc6ba53f6 Handle Blob responses in AuthService.login and enhance JWT decoding logic. Add utility methods for Blob detection and conversion. 2025-07-06 20:53:13 +02:00
stijn 4c38b070ea Handle Blob responses in AuthService.login and enhance JWT decoding logic. Add utility methods for Blob detection and conversion. 2025-07-06 20:53:03 +02:00
stijn db394beda6 Add debug logs for JWT and its decoded value in AuthService. 2025-07-06 20:32:02 +02:00
stijn 76cb3cd89c Decode JWT before saving in AuthService. 2025-07-06 20:09:49 +02:00
stijn 5d8ab2deef Add debug log for generated token in LoginController. 2025-07-06 19:39:46 +02:00
stijn aef32a8982 Change log level to DEBUG in beta configuration and improve logging in LoginController. 2025-07-06 19:23:38 +02:00
stijn 42f0961f13 Directly initialize JwtHelperService in auth service 2025-07-06 19:14:06 +02:00
stijn 04310e1cce Switch to localStorage for JWT handling and simplify case transformation logic in LoginComponent. Update app.config.ts and related services to align with the new token management method. Mark JwtClaims interface as exported. 2025-07-06 19:10:17 +02:00
Peter 54e747118c Merge branch 'bans' 2025-07-06 18:15:09 +02:00
Peter 43430cfbef Merge remote-tracking branch 'origin/bans' into bans
# Conflicts:
#	frontend/src/app/app.routes.ts
2025-07-06 11:13:59 +02:00
stijn cce83a08de Replace fakeLogin() with actual login() method in AuthService and remove redundant fakeLogin() implementation. 2025-07-04 23:32:08 +02:00
stijn f0faa63ca7 Add JWT support for authentication handling
Integrate `@auth0/angular-jwt` for Token management. Update `app.config.ts` with `JwtModule` setup and token getter from cookies. Enhance `AuthService` to include token handling, fake login, and JWT validation using `JwtHelperService`. Introduce `JwtClaims` interface for structured token claims.
2025-07-04 22:31:41 +02:00
stijn dfea91d8ca Add PrivilegedUserMapper to InitializeWebDb setup 2025-07-04 21:37:11 +02:00
stijn 73916f0aae Add login button to header 2025-07-04 21:14:45 +02:00
stijn ebe66c87c0 Rework folder structure in frontend
Pages are now grouped per group they appear in on in the header (where possible)
Utilities used by multiple pages in the project are grouped in folders such as services/pipes/etc
2025-07-04 19:50:21 +02:00
stijn c42fc38b2c Add SecurityAuthFailureHandler for better handling of authentication and access failures; update SecurityConfig to integrate the new handler. 2025-07-04 19:49:04 +02:00
stijn 213f9987d9 Remove particle component and its associated routes and security controls. 2025-07-03 20:08:56 +02:00
stijn 48cac607de Add route for lazy-loaded Login component. 2025-07-03 20:02:07 +02:00
stijn 6ed2e15017 Parametrize notification server URL configuration for improved flexibility. 2025-06-29 03:17:25 +02:00
stijn 7fc25f46f3 Add endpoints, services, and security controls for particle file management, including save and download APIs. 2025-06-29 03:15:39 +02:00
stijn c72703ea32 Refactor user privilege handling to use Optional instead of null checks. Remove unused cache entries and update security configuration to refine access controls. 2025-06-23 21:34:54 +02:00
auto e837a9216d Fix slider sticking out of page 2025-06-23 00:25:31 +02:00
auto d4363b3a8a Add particle type selection, size control, and enhance particle property handling 2025-06-23 00:23:03 +02:00
auto 1e5862bae6 Add new particle types and enhance particle attributes handling 2025-06-23 00:04:30 +02:00
auto daf88ea437 Add opacity control for intersection plane 2025-06-22 23:24:06 +02:00
auto 9abd570b87 Add support for darkmode 2025-06-22 23:15:06 +02:00
auto 5284d498f3 Add a reset camera button and implement default camera reset functionality 2025-06-22 20:53:11 +02:00
auto c3a7be82e9 Add an option to highlight particles 2025-06-22 20:46:08 +02:00
auto fdb57289f8 Fix weird interactions between plane and player model 2025-06-22 20:20:40 +02:00
auto 60c1329163 Switch to steve 2025-06-22 20:17:02 +02:00
auto 0e71c0f581 Fix textures overlapping and glitching for legs 2025-06-22 20:15:35 +02:00
auto eb67a33331 Change default camera position 2025-06-22 20:12:25 +02:00
auto 39f20796ce Simplify UV mapping logic and remove unnecessary face rotation handling 2025-06-22 20:08:42 +02:00
auto e00165c56f Fix uv rotation 2025-06-22 20:04:26 +02:00
auto 02c6497700 Fix mapping 2025-06-22 20:01:11 +02:00
auto 0efd476676 Initial attempt at textures 2025-06-22 19:53:27 +02:00
auto 237518638c Increase particles-list height in FramesComponent to 550px for improved visibility. 2025-06-22 19:34:20 +02:00
auto fea1a98cea Adjust RendererService to use dynamic container height and update OrbitControls limits 2025-06-22 19:27:55 +02:00
auto ecd9b3d824 Modularize renderer and plane control functionality into RenderContainerComponent 2025-06-22 19:23:54 +02:00
auto 9808b5d63d Add manual plane orientation controls with lock/unlock functionality
Implemented a UI overlay in `ParticlesComponent` for manual plane orientation selection with buttons for different orientations. Added lock/unlock toggle to control automatic orientation adjustment. Refactored `IntersectionPlaneService` to support locked state and manual orientation updates. Updated styles and layout to integrate the new controls seamlessly.
2025-06-22 19:16:32 +02:00
auto c13b7077a7 Remove unused OnInit lifecycle hook from ParticlesComponent and cleanup redundant comments. 2025-06-22 19:08:28 +02:00
auto 023ae809ef Modularize ParticlesComponent by separating particle, frame, and property functionalities into dedicated components
Refactored `ParticlesComponent` to use `ParticleComponent`, `FramesComponent`, and `PropertiesComponent` for better organization and reusability. Updated layout, improved UI structure, and centralized particle management logic. Enhanced clipboard functionality with a new JSON copy feature.
2025-06-22 19:06:52 +02:00
auto 3a6f137c9a Update ParticlesComponent to allow dynamic plane position limits and improve slider configuration
Modified plane position logic to use dynamic min/max offsets based on orientation. Enhanced slider component with binding to these offsets and updated display text for clarity. Refactored `IntersectionPlaneService` to expose new methods (`getMaxOffset`, `getMinOffset`) and adjusted position defaults.
2025-06-22 17:59:29 +02:00
auto cb8447a096 Correct default plane locations 2025-06-22 17:38:28 +02:00
auto 3e98e1a498 add plane orientation for looking from above/below 2025-06-22 17:32:32 +02:00
auto 4c31a91bb4 Add modular services for Three.js integration in ParticlesComponent
Implemented dedicated Angular services (e.g., RendererService, InputHandlerService, PlayerModelService) to modularize and simplify Three.js integration within the `ParticlesComponent`. Refactored component logic to delegate rendering, input handling, and model creation to respective services. Updated particle data models and removed redundant logic from the component.
2025-06-22 17:26:10 +02:00
auto d6faaba01c Update security configuration to allow unrestricted access
Changed default security rule to permit all requests instead of requiring authentication. Adjusted `SecurityConfig` to ensure open access for all endpoints.
2025-06-22 01:15:45 +02:00
auto 56175e62d6 Remove RemoveTrailingPeriodPipe import from AboutComponent 2025-06-22 01:10:53 +02:00
auto 283838f444 Enhance ParticlesComponent to adjust plane position dynamically based on rotation angle. 2025-06-22 01:10:21 +02:00
auto 49fe335c73 Refactor ParticlesComponent: simplify plane creation, remove legacy handler, and enhance quadrant logic with material updates. 2025-06-22 01:03:17 +02:00
auto 1875f050c6 Add orbit controls and mouse interaction to ParticlesComponent
Integrated Three.js `OrbitControls` for smoother camera navigation and implemented new mouse interaction methods (`onMouseDown`, `onMouseUp`, `onMouseMove`) for enhanced usability. Adjusted scene, camera, and renderer setup for better responsiveness.
2025-06-22 00:55:38 +02:00
auto 52d8658be3 Add particle creator component with Three.js integration CREDITS: Junie
Introduced the `ParticlesComponent` for creating and visualizing particles using Three.js. Added associated routes, HTML, SCSS, and tests. Updated `package.json` to include `three` and `@types/three` as dependencies. Adjusted app navigation to include the particles page.
2025-06-22 00:40:16 +02:00
auto 32a454c034 Refactor permission handling and authentication services
Replaced `PermissionClaim` enum with an OpenAPI-defined schema `PermissionClaimDto` for consistency across frontend and backend. Refactored authentication flow to utilize `AuthService` on the frontend, consolidating JWT handling logic. Removed redundant methods like `saveJwt` and integrated robust permission management throughout the application.
2025-06-21 23:15:46 +02:00
auto 07646e8c42 Implement enhanced login functionality with JWT, role-based permissions, and frontend integration
Added JWT-based login dialog with form validation and secure token handling on the frontend. Updated backend with role-based access control, privilege management, and refined security configurations. Extended database schema for user privileges and permissions.
2025-05-30 23:41:13 +02:00
Peter 174ed834ca Added pages and fitting content for community, nickgenerator annd nicknames 2025-05-30 23:07:42 +02:00
Peter 20dcebbab9 Added new pages with fitting content: skyblock, custom features, guide book, ranks, commandlist, reducing lag, staff powers 2025-05-30 22:19:36 +02:00
auto c4c17b3adc Add JWT-based login flow with key pair generation
Introduced a secure login flow using JWTs with dynamically generated RSA key pairs stored in the database. Updated relevant APIs, database schema, and services to support login codes, JWT encoding, and secret validation.
2025-05-24 01:33:36 +02:00
auto cf758bfe60 Add endpoints and schema for history retrieval by UUID
Introduced a new API endpoint to fetch all punishment history for a specified UUID. Updated existing schemas, controllers, and mappers to support this functionality. Adjusted login endpoints to improve request handling and streamlined frontend form setup for appeals.
2025-05-03 04:37:47 +02:00
auto 8c7ec0a237 Restrict team member query to global world scope. 2025-05-02 01:33:45 +02:00
auto 80462218a7 Update role label for head mod 2025-05-02 01:32:15 +02:00
auto 26b5f86983 Add rate limiting to LoginController endpoints
Introduced a `@RateLimit` annotation to enforce limits on the `addLogin` and `login` methods in `LoginController`. This restricts the number of requests per minute to improve security and prevent abuse.
2025-04-26 23:14:33 +02:00
auto ba6cf6d938 Add rate limiting to AppealController methods
Introduced @RateLimit annotations to enforce request limits on the AppealController. The overall controller has a global limit of 30 requests per hour, while specific methods for Discord and Minecraft appeals are limited to 3 requests per hour. This aims to prevent abuse and improve system reliability.
2025-04-26 23:13:26 +02:00
auto 643545a18a Add appeal and login functionality structure
Introduces initial structure for appeal and login forms in both the frontend and backend. New controllers, APIs, and components were created, but functionality has not been fully implemented yet. This serves as a foundation for future development of these features.
2025-04-26 20:58:47 +02:00
auto b922487d76 Fix punishment tag colors and avatar image size 2025-04-20 17:29:51 +02:00
auto 54eb1ea735 Fixes #15 2025-04-20 17:19:50 +02:00
Peter 9043c774f7 Refactor ban details layout and enhanced formatting.
Updated the HTML structure and styling for a clearer, responsive layout and better user experience.
2025-04-20 17:00:31 +02:00
auto 43b75b8e74 Fixed punishment removed message closes #12 2025-04-19 22:19:08 +02:00
auto 07c77f5485 Update ban history formatting to include clearer unban details
Modified the history format service to display 'Unbanned by [user]' instead of just the user's name. This provides more explicit context for removed punishments in the UI.
2025-04-19 22:13:19 +02:00
Peter d54a7e51ee Add MyPet and Warps components with HTML, SCSS
Implemented the MyPet and Warps components, including their HTML structure, styles, and unit tests. These components provide detailed information and features for MyPet and Player Warps systems for a Minecraft-inspired application.
2025-04-19 16:37:27 +02:00
auto 6be6944dea Update rate limit for HistoryApiController
Changed the rate limit from 30 requests per minute to 30 requests per 10 seconds. This ensures a smoother request flow and prevents excessive delays for frequent API users.
2025-04-19 05:00:41 +02:00
auto 3babde5513 Add punishment details and history retrieval functionality
This commit introduces a new `DetailsComponent` for displaying detailed punishment data and establishes a route to view punishment history by ID and type. It also updates the API to support fetching individual punishment records and refines database mappings for improved data handling.
2025-04-19 04:02:51 +02:00
auto a1912b96d8 Update favicon 2025-04-19 01:40:14 +02:00
auto e9880f8864 Remove redundant 'logging' property from angular.json
The 'logging' property was removed from the build configurations as it is not used or required. This simplifies the configuration and avoids potential confusion.
2025-04-19 01:34:31 +02:00
auto 3449cf24b1 Remove extractCss configuration from angular.json
The extractCss option is deprecated and no longer required. This change cleans up the configuration by removing obsolete settings, ensuring compatibility with newer Angular versions.
2025-04-19 01:33:41 +02:00
auto 43edd49273 Set up environment-based configurations for API and builds.
Replaced hardcoded API URLs with environment-specific configurations. Added new environments (development, beta, production) with respective settings. Updated build scripts and Angular file replacements to support environment-based builds.
2025-04-19 01:28:29 +02:00
auto 3d4d77bc73 Add WebConfig for static resource handling
Introduced WebConfig to configure resource handling in the backend. This ensures SPA fallback by serving `index.html` for non-existent paths, improving routing for client-side applications.
2025-04-19 01:07:49 +02:00
auto 5b158ae3f7 Add application-beta.properties for beta environment config. 2025-04-19 01:01:48 +02:00
auto bbedd2257e Ensure backend tasks depend on frontend build completion 2025-04-19 00:44:52 +02:00
auto 6f2a48338b Fix npm executable path resolution in build script 2025-04-19 00:09:33 +02:00
auto e6df93ff5c Fix npm executable path resolution in build script 2025-04-19 00:08:13 +02:00
Peter 680506997e Add Claiming and Economy pages with routing and styles
Created new Claiming and Economy components, templates, styles, and tests. Integrated both pages into the app's routing, enabling navigation and functionality. Updated home page layout and added necessary utility styles.
2025-04-18 22:52:05 +02:00
auto 235f4fb60f Refactor database queries and handle non-429 errors in history
Updated database query code to use consistent parameter naming and revised query structure for clarity. Added logic in the history component to return existing data when encountering non-429 HTTP errors.
2025-04-18 22:41:04 +02:00
auto 6dbb36399f Add 'node' to .gitignore
Ignore 'node' files to prevent accidental commits of local Node.js-related artifacts. This ensures a cleaner repository with only necessary files tracked.
2025-04-18 22:27:42 +02:00
auto 25e8dc8e8e Update build scripts to integrate frontend with backend. 2025-04-18 22:24:55 +02:00
416 changed files with 22323 additions and 1048 deletions
+4
View File
@@ -90,3 +90,7 @@ frontend/src/api
generated
liquibase*.properties
node
.env
virtual_view/*
+29 -42
View File
@@ -2,7 +2,6 @@ plugins {
java
id("org.springframework.boot") version "3.4.4"
id("io.spring.dependency-management") version "1.1.7"
// id("com.github.johnrengelman.shadow") version "8.1.1"
}
group = "com.alttd.altitudeweb"
@@ -27,14 +26,24 @@ repositories {
dependencies {
implementation(project(":open_api"))
implementation(project(":database"))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation(project(":frontend"))
implementation(project(":discord"))
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-test")
implementation("com.mysql:mysql-connector-j:8.0.32")
implementation("org.mybatis:mybatis:3.5.13")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-configuration-processor")
implementation("org.springframework.boot:spring-boot-starter-hateoas")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.security:spring-security-oauth2-resource-server")
implementation("org.springframework.security:spring-security-oauth2-jose")
implementation("org.springframework.boot:spring-boot-starter-mail:3.1.5")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
//Open API
implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
implementation("io.swagger.core.v3:swagger-models:2.2.37")
implementation("org.springframework.boot:spring-boot-starter-actuator")
//AOP
implementation("org.aspectj:aspectjrt:1.9.19")
@@ -42,6 +51,12 @@ dependencies {
implementation("org.springframework:spring-aop")
implementation("org.springframework:spring-aspects")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.compileJava {
dependsOn(":frontend:npmBuild")
}
tasks.withType<Test> {
@@ -54,42 +69,14 @@ tasks.bootJar {
archiveClassifier.set("")
}
tasks.processResources {
dependsOn("includeFrontend")
}
//tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
// mergeServiceFiles()
// dependencies {
// include(dependency("com.mysql:mysql-connector-j"))
// }
//}
//tasks.withType<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar> {
// manifest {
// attributes["Main-Class"] = "com.alttd.altitudeweb.AltitudeWebApplication"
// }
// archiveBaseName.set("altitudeweb")
// archiveClassifier.set("")
// mergeServiceFiles()
//
// // Include everything
// from(sourceSets.main.get().output)
//
// // Include all project dependencies
// configurations = listOf(project.configurations.runtimeClasspath.get())
//
// // Ensure MySQL is included (even though it should be part of runtimeClasspath already)
// dependencies {
// include(dependency("com.mysql:mysql-connector-j"))
// }
//
// // Enable zip64 mode for large JARs
// isZip64 = true
//}
//
//// Make the shadowJar task the default jar task
//tasks.named("jar") {
// enabled = false
//}
//
//tasks.named("assemble") {
// dependsOn("shadowJar")
//}
tasks.register<Copy>("includeFrontend") {
description = "Copy the built frontend to the Spring Boot static resources directory"
dependsOn(":frontend:npmBuild")
from("${project.rootDir}/frontend/dist")
into(layout.buildDirectory.dir("resources/main/static"))
doNotTrackState("Cannot reliably track state in the static directory")
}
@@ -4,14 +4,16 @@ import com.alttd.altitudeweb.setup.Connection;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@SpringBootApplication(scanBasePackages = {"com.alttd.altitudeweb"})
@EnableAspectJAutoProxy
@EnableScheduling
public class AltitudeWebApplication {
public static void main(String[] args) {
SpringApplication.run(AltitudeWebApplication.class, args);
Connection.initDatabases();
SpringApplication.run(AltitudeWebApplication.class, args);
}
}
@@ -0,0 +1,36 @@
package com.alttd.altitudeweb.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Slf4j
@Component
public class SecurityAuthFailureHandler implements AccessDeniedHandler, AuthenticationEntryPoint {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
log.warn("Access denied: User '{}' attempted to access '{}' without proper permissions",
request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : "unknown",
request.getRequestURI());
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
log.warn("Authentication failure: Unauthenticated user attempted to access secured endpoint '{}'",
request.getRequestURI());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication Required");
}
}
@@ -0,0 +1,159 @@
package com.alttd.altitudeweb.config;
import com.alttd.altitudeweb.controllers.login.KeyPairService;
import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager;
import java.security.KeyPair;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final KeyPairService keyPairService;
private final SecurityAuthFailureHandler securityAuthFailureHandler;
@Value("${chat.allowed-ip}")
private String allowedIp;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
WebExpressionAuthorizationManager allowedIpCheck = new WebExpressionAuthorizationManager(
"hasIpAddress('%s')".formatted(allowedIp));
return http
.authorizeHttpRequests(
auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/chat/send/**").access(allowedIpCheck)
.requestMatchers("/actuator/mappings").access(allowedIpCheck)
.requestMatchers("/api/form/**").authenticated()
.requestMatchers("/api/login/getUsername").authenticated()
.requestMatchers("/api/mail/**").authenticated()
.requestMatchers("/api/site/vote").authenticated()
.requestMatchers("/api/appeal").authenticated()
.requestMatchers(HttpMethod.POST, "/api/chat/hide").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers(HttpMethod.POST, "/api/chat/show").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers(HttpMethod.GET, "/api/chat/visibility").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/chat/read/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.MOD.getValue())
.requestMatchers("/api/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/particles/**").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/login/userLogin/**").permitAll()
.requestMatchers("/api/site/chat/**").authenticated()
.requestMatchers("/api/site/chat/send-admin-chat/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.MOD.getValue())
.requestMatchers("/api/site/chat/punish/**").hasAnyAuthority(PermissionClaimDto.HEAD_MOD.getValue(), PermissionClaimDto.MOD.getValue())
.requestMatchers("/api/chat/server/send/**").permitAll()
.anyRequest().permitAll()
)
.csrf(AbstractHttpConfigurer::disable)
.oauth2ResourceServer(
oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
.authenticationEntryPoint(securityAuthFailureHandler)
.accessDeniedHandler(securityAuthFailureHandler)
)
.exceptionHandling(
ex -> ex
.authenticationEntryPoint(securityAuthFailureHandler)
.accessDeniedHandler(securityAuthFailureHandler)
)
.sessionManagement(
session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
@Bean
public JwtEncoder jwtEncoder() {
KeyPair keyPair = keyPairService.getJwtSigningKeyPair();
JWK jwk = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
.privateKey((RSAPrivateKey) keyPair.getPrivate())
.build();
JWKSource<SecurityContext> jwkSource = new ImmutableJWKSet<>(new JWKSet(jwk));
return new NimbusJwtEncoder(jwkSource);
}
@Bean
public JwtDecoder jwtDecoder() {
KeyPair keyPair = keyPairService.getJwtSigningKeyPair();
return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> claims = jwt.getClaims();
Object authoritiesClaim = claims.get("authorities");
if (authoritiesClaim instanceof List<?> authorities) {
Collection<GrantedAuthority> authorityList = authorities.stream()
.map(Object::toString)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
log.debug("Authorities found in authorities: {}", authorityList);
return authorityList;
}
Object scopeClaim = claims.get("scope");
if (scopeClaim instanceof String scopeString) {
Collection<GrantedAuthority> authorityList = Arrays.stream(scopeString.split(" "))
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
.collect(Collectors.toList());
log.debug("Authorities found in authorities scope string: {}", authorityList);
return authorityList;
}
if (scopeClaim instanceof List<?> scopeList) {
Collection<GrantedAuthority> authorityList = scopeList.stream()
.map(Object::toString)
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
.collect(Collectors.toList());
log.debug("Authorities found in authorities scope list: {}", authorityList);
return authorityList;
}
log.debug("No granted authorities found");
return Collections.emptyList();
});
return converter;
}
}
@@ -0,0 +1,48 @@
package com.alttd.altitudeweb.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import java.io.IOException;
@Slf4j @Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/browser")
.resourceChain(true)
.addResolver(new PathResourceResolver() {
@Override
protected Resource getResource(String resourcePath, Resource location) throws IOException {
Resource requestedResource = location.createRelative(resourcePath);
if (requestedResource.exists() && requestedResource.isReadable()) {
log.debug("Serving resource {} from {}", resourcePath, location);
return requestedResource;
}
log.debug("Resource {} not found in {}, serving index.html", resourcePath, location);
return new ClassPathResource("/static/browser/index.html");
}
});
}
@Controller
public static class HomeController {
@GetMapping("/")
public String index() {
return "forward:/index.html";
}
}
}
@@ -18,7 +18,7 @@ public class CorsConfig implements WebMvcConfigurer {
log.info("Registering CORS mappings for {}", String.join(", ", allowedOrigins));
registry.addMapping("/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true);
}
@@ -0,0 +1,60 @@
package com.alttd.altitudeweb.controllers.chat;
import com.alttd.altitudeweb.api.ChatApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.model.ChatMessageDto;
import com.alttd.altitudeweb.model.PlayerListVisibilityDto;
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;
import java.util.UUID;
@Slf4j
@RestController
@RequiredArgsConstructor
public class ChatController implements ChatApi {
private final ChatService chatService;
private final AuthenticatedUuid authenticatedUuid;
@Override
public ResponseEntity<Void> hideUserFromPlayerList() {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
chatService.hideUserFromPlayerList(uuid);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
@Override
public ResponseEntity<Void> showUserInPlayerList() {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
chatService.showUserInPlayerList(uuid);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
@Override
public ResponseEntity<PlayerListVisibilityDto> getPlayerListVisibility() {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
PlayerListVisibilityDto visibility = new PlayerListVisibilityDto()
.hidden(chatService.isUserHiddenFromPlayerList(uuid));
return ResponseEntity.ok(visibility);
}
@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) {
chatService.updateServerState(serverStateDto);
return new ResponseEntity<>(HttpStatus.ACCEPTED);
}
}
@@ -0,0 +1,46 @@
package com.alttd.altitudeweb.controllers.chat;
import com.alttd.altitudeweb.api.ChatInfoApi;
import com.alttd.altitudeweb.model.PartyNameDto;
import com.alttd.altitudeweb.model.PlayerNameDto;
import com.alttd.altitudeweb.services.chat.ChatInfoService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@Slf4j
@RestController
@RequiredArgsConstructor
public class ChatInfoController implements ChatInfoApi {
private final ChatInfoService chatInfoService;
@Override
public ResponseEntity<PlayerNameDto> getName(UUID uuid) {
return chatInfoService.getPlayerName(uuid)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@Override
public ResponseEntity<PartyNameDto> getPartyName(String id) {
int partyId;
try {
partyId = Integer.parseInt(id);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().build();
}
return chatInfoService.getPartyName(partyId)
.map(partyName -> {
PartyNameDto partyNameDto = new PartyNameDto();
partyNameDto.setName(partyName);
return partyNameDto;
})
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
@@ -0,0 +1,23 @@
package com.alttd.altitudeweb.controllers.chat;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import java.time.Instant;
import java.util.UUID;
@Builder
@Getter
public class ChatMessage {
private final UUID uuid;
@Setter
private Instant timestamp;
private final String server;
private final ChatMessageType type;
private final String channel;
private final String receiver;
private final String messageJson;
private final boolean notBlocked;
}
@@ -0,0 +1,77 @@
package com.alttd.altitudeweb.controllers.chat;
import com.alttd.altitudeweb.database.chat.ChatLogDao;
import com.alttd.altitudeweb.model.ChatMessageDto;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.experimental.UtilityClass;
import java.util.UUID;
@UtilityClass
public class ChatMessageMapper {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule())
.enable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS);
public static ChatMessage fromDto(ChatMessageDto dto) {
return ChatMessage.builder()
.uuid(dto.getUuid())
.timestamp(dto.getTimestamp().toInstant())
.server(dto.getServer())
.type(fromDto(dto.getType()))
.channel(dto.getChannel())
.receiver(dto.getReceiver())
.messageJson(dto.getMessage())
.notBlocked(!dto.getBlocked())
.build();
}
private static ChatMessageType fromDto(ChatMessageDto.TypeEnum type) {
return switch (type) {
case PUBLIC -> ChatMessageType.PUBLIC;
case GLOBAL -> ChatMessageType.GLOBAL;
case PARTY -> ChatMessageType.PARTY;
case GAC -> ChatMessageType.GAC;
case MSG -> ChatMessageType.MSG;
case CUSTOM -> ChatMessageType.CUSTOM;
case UNKNOWN_DEFAULT_OPEN_API -> throw new IllegalArgumentException("Unknown chat message type");
};
}
public static ChatMessage fromDao(ChatLogDao dao) {
return ChatMessage.builder()
.uuid(UUID.fromString(dao.getUuid()))
.timestamp(dao.getTimeStamp().toInstant())
.server(dao.getServer())
.type(fromString(dao.getType()))
.channel(dao.getChannel())
.receiver(dao.getReceiver())
.messageJson(dao.getMiniMessage())
.notBlocked(!dao.isBlocked())
.build();
}
private static ChatMessageType fromString(String type) {
return switch (type) {
case "public" -> ChatMessageType.PUBLIC;
case "global" -> ChatMessageType.GLOBAL;
case "party" -> ChatMessageType.PARTY;
case "gac" -> ChatMessageType.GAC;
case "msg" -> ChatMessageType.MSG;
case "custom" -> ChatMessageType.CUSTOM;
default -> throw new IllegalArgumentException("Unknown chat message type");
};
}
public static String toJson(ChatMessage chatMessage) {
try {
return OBJECT_MAPPER.writeValueAsString(chatMessage);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize ChatMessage to JSON: " + chatMessage.getUuid(), e);
}
}
}
@@ -0,0 +1,10 @@
package com.alttd.altitudeweb.controllers.chat;
public enum ChatMessageType {
PUBLIC,
GLOBAL,
PARTY,
GAC,
MSG,
CUSTOM;
}
@@ -0,0 +1,93 @@
package com.alttd.altitudeweb.controllers.chat;
import com.alttd.altitudeweb.api.ServerMessageApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.model.PartyMessageRequestDto;
import com.alttd.altitudeweb.model.PrivateMessageRequestDto;
import com.alttd.altitudeweb.model.PunishRequestDto;
import com.alttd.altitudeweb.model.ServerMessageRequestDto;
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
import com.alttd.altitudeweb.services.chat.to_server.data.ChatFromWeb;
import com.alttd.altitudeweb.services.chat.to_server.data.PartyChatFromWeb;
import com.alttd.altitudeweb.services.chat.to_server.data.PrivateChatFromWeb;
import com.alttd.altitudeweb.services.chat.to_server.data.PunishFromWeb;
import com.alttd.altitudeweb.services.chat.to_server.mappers.ChatFromWebMapper;
import com.alttd.altitudeweb.services.chat.to_server.mappers.PartyChatFromWebMapper;
import com.alttd.altitudeweb.services.chat.to_server.mappers.PrivateChatFromWebMapper;
import com.alttd.altitudeweb.services.chat.to_server.mappers.PunishFromWebMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.UUID;
@Slf4j
@RestController
@RequiredArgsConstructor
public class ServerMessageController implements ServerMessageApi {
private final ServerMessageService serverMessageService;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final AuthenticatedUuid authenticatedUuid;
public static String toJson(Object object) {
try {
return OBJECT_MAPPER.writeValueAsString(object);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize object to JSON", e);
}
}
@Override
public ResponseEntity<Void> punishPlayer(String server, PunishRequestDto punishRequestDto) throws Exception {
validateUser(punishRequestDto.getExecutor());
return send(server, "web_punish", PunishFromWebMapper.fromDto(punishRequestDto));
}
@Override
public ResponseEntity<Void> sendAdminChat(String server, ServerMessageRequestDto serverMessageRequestDto) {
validateUser(serverMessageRequestDto.getUuid());
return send(server, "web_ac_chat", ChatFromWebMapper.fromDto(serverMessageRequestDto));
}
@Override
public ResponseEntity<Void> sendPartyMessage(PartyMessageRequestDto partyMessageRequestDto) {
validateUser(partyMessageRequestDto.getFrom());
return send("proxy", "web_party_chat", PartyChatFromWebMapper.fromDto(partyMessageRequestDto));
}
@Override
public ResponseEntity<Void> sendPrivateMessage(PrivateMessageRequestDto privateMessageRequestDto) {
validateUser(privateMessageRequestDto.getFrom());
return send("proxy", "web_private_chat", PrivateChatFromWebMapper.fromDto(privateMessageRequestDto));
}
@Override
public ResponseEntity<Void> sendServerMessage(String server, ServerMessageRequestDto serverMessageRequestDto) {
validateUser(serverMessageRequestDto.getUuid());
//TODO [Stijn] [2026-08-02]: Validate the user can send a message in this server (or do that in chat)
return send(server, "web_chat", ChatFromWebMapper.fromDto(serverMessageRequestDto));
}
private void validateUser(UUID uuid) {
UUID authenticatedUserUuid = authenticatedUuid.getAuthenticatedUserUuid();
if (!authenticatedUserUuid.equals(uuid)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot send message as another user");
}
}
private ResponseEntity<Void> send(String server, String channel, Object data) {
String json = toJson(data);
if (serverMessageService.sendMessage(server, channel, json)) {
return new ResponseEntity<>(HttpStatus.ACCEPTED);
} else {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
}
}
@@ -0,0 +1,24 @@
package com.alttd.altitudeweb.controllers.chat;
import com.alttd.altitudeweb.model.ServerStateDto;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.experimental.UtilityClass;
@UtilityClass
public class ServerStateMapper {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule())
.enable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS);
public static String toJson(ServerStateDto serverStateDto) {
try {
return OBJECT_MAPPER.writeValueAsString(serverStateDto);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize ServerStateDto to JSON", e);
}
}
}
@@ -0,0 +1,73 @@
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.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import java.util.UUID;
@Slf4j
@Service
public class AuthenticatedUuid {
@Value("${UNSECURED:#{false}}")
private boolean unsecured;
/**
* Extracts and validates the authenticated user's UUID from the JWT token.
*
* @return The UUID of the authenticated user
* @throws ResponseStatusException with 401 status if authentication is invalid
*/
public UUID getAuthenticatedUserUuid() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
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) {
return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f");
}
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
}
String stringUuid = jwt.getSubject();
try {
return UUID.fromString(stringUuid);
} catch (IllegalArgumentException e) {
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,48 @@
package com.alttd.altitudeweb.controllers.event;
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.token.Token;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RequiredArgsConstructor
@Slf4j
@RestController
@RequestMapping("/api/chat/server/send")
public class CommandsToServerController {
private final ServerMessageService serverMessageService;
@Value("${altitudeweb.token}")
private String validToken;
@GetMapping(path = "/subscribe/{server}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@RequestHeader(value = "X-Altitude-Token", required = false) String token,
HttpServletResponse response, @PathVariable String server) {
if (validToken == null || validToken.equals("invalid-token")) {
log.error("Invalid token in config");
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
if (token == null) {
log.error("No token provided when trying to subscribe to {}", server);
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if (!token.equals(validToken)) {
log.error("Invalid token provided when trying to subscribe to {}", server);
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
response.setHeader("X-Accel-Buffering", "no"); // disables nginx buffering if present
response.setHeader("Cache-Control", "no-cache");
return serverMessageService.subscribe(server);
}
}
@@ -0,0 +1,100 @@
package com.alttd.altitudeweb.controllers.event;
import com.alttd.altitudeweb.controllers.chat.ChatMessageMapper;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.chat.ChatLogMapper;
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSession;
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSessionMapper;
import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.services.chat.ChatService;
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
import com.alttd.altitudeweb.setup.Connection;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Slf4j
@RestController
@RequestMapping("/api/chat/read")
public class EventController {
private final static Duration MESSAGE_HISTORY_DURATION = Duration.ofHours(1);
private final ChatService chatService;
@GetMapping(path = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@AuthenticationPrincipal Jwt jwt,
HttpServletResponse response) {
if (jwt == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
List<String> authorities = jwt.getClaimAsStringList("authorities");
if (authorities == null || doesNotHavePermission(authorities)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
response.setHeader("X-Accel-Buffering", "no"); // disables nginx buffering if present
response.setHeader("Cache-Control", "no-cache");
UUID subject;
try {
subject = UUID.fromString(jwt.getSubject());
} catch (IllegalArgumentException e) {
log.error("Invalid UUID in JWT subject: {}", jwt.getSubject());
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
String json = chatService.getMessagesSince(Instant.now().minus(MESSAGE_HISTORY_DURATION))
.stream()
.map(ChatMessageMapper::toJson)
.collect(Collectors.joining(",", "[", "]"));
Integer partyId = getPartyId(subject);
EventUser eventUser = new EventUser(subject, authorities, partyId);
return chatService.subscribe(eventUser, json);
}
private Integer getPartyId(UUID subject) {
CompletableFuture<Integer> partyIdFuture = new CompletableFuture<>();
Connection.getConnection(Databases.CHAT)
.runQuery(sqlSession -> {
log.debug("Loading party id for uuid {}", subject);
try {
Integer partyId = sqlSession.getMapper(ChatLogMapper.class)
.getPartyId(subject.toString())
.orElse(null);
partyIdFuture.complete(partyId);
} catch (Exception e) {
log.error("Failed to load party id", e);
partyIdFuture.complete(null);
}
});
return partyIdFuture.join();
}
private static boolean doesNotHavePermission(List<String> authorities) {
return !authorities.contains(PermissionClaimDto.HEAD_MOD.getValue()) && !authorities.contains(PermissionClaimDto.MOD.getValue());
}
}
@@ -0,0 +1,144 @@
package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.AppealsApi;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.litebans.HistoryType;
import com.alttd.altitudeweb.database.litebans.IdHistoryMapper;
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.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.mappers.AppealDataMapper;
import com.alttd.altitudeweb.model.*;
import com.alttd.altitudeweb.services.forms.DiscordAppeal;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.mail.AppealMail;
import com.alttd.altitudeweb.setup.Connection;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@AllArgsConstructor
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class AppealController implements AppealsApi {
private final AppealDataMapper mapper;
private final AppealMail appealMail;
private final DiscordAppeal discordAppeal;
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")
@Override
public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
return new ResponseEntity<>(discordAppeal.submitAppeal(discordAppealDto), HttpStatus.OK);
}
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
@Override
public ResponseEntity<FormResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
boolean success = true;
CompletableFuture<Appeal> appealCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Loading history by id");
try {
Appeal appeal = mapper.minecraftAppealDtoToAppeal(minecraftAppealDto);
sqlSession.getMapper(AppealMapper.class).createAppeal(appeal);
appealCompletableFuture.complete(appeal);
} catch (Exception e) {
log.error("Failed to load history count", e);
appealCompletableFuture.completeExceptionally(e);
}
});
Appeal appeal = appealCompletableFuture.join();
HistoryRecord history = getHistory(appeal.historyType(), appeal.historyId());
if (history == null) {
throw new ResponseStatusException(HttpStatusCode.valueOf(404), "History not found");
}
// Send to Discord channels
try {
appealDiscord.sendAppealToDiscord(appeal, history);
} catch (Exception e) {
log.error("Failed to send appeal {} to Discord", appeal.id(), e);
success = false;
}
appealMail.sendAppealNotification(appeal, history);
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(appeal.uuid(), appeal.email().toLowerCase());
emailVerificationCompletableFuture.complete(Optional.ofNullable(verifiedMail));
});
Optional<EmailVerification> optionalEmailVerification = emailVerificationCompletableFuture.join();
if (optionalEmailVerification.isEmpty()) {
return ResponseEntity.badRequest().build();
}
EmailVerification emailVerification = optionalEmailVerification.get();
if (!emailVerification.verified()) {
return ResponseEntity.badRequest().build();
}
if (!success) {
return ResponseEntity.internalServerError().build();
}
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Marking appeal {} as sent", appeal.id());
sqlSession.getMapper(AppealMapper.class)
.markAppealAsSent(appeal.id());
});
FormResponseDto appealResponseDto = new FormResponseDto(
appeal.id().toString(),
"Your appeal has been submitted. You will be notified when it has been reviewed.",
true);
return ResponseEntity.ok().body(appealResponseDto);
}
@Override
public ResponseEntity<FormResponseDto> updateMail(UpdateMailDto updateMailDto) {
//TODO move to its own endpoint
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Updating mail is not yet supported");
}
private HistoryRecord getHistory(String type, int id) {
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<HistoryRecord> historyRecordCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
.runQuery(sqlSession -> {
log.debug("Loading history by id");
try {
HistoryRecord punishment = sqlSession.getMapper(IdHistoryMapper.class)
.getRecentHistory(historyTypeEnum, id);
historyRecordCompletableFuture.complete(punishment);
} catch (Exception e) {
log.error("Failed to load history count", e);
historyRecordCompletableFuture.completeExceptionally(e);
}
});
return historyRecordCompletableFuture.join();
}
}
@@ -0,0 +1,155 @@
package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.ApplicationsApi;
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.StaffApplication;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.mappers.StaffApplicationDataMapper;
import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.model.StaffApplicationDto;
import com.alttd.altitudeweb.services.discord.StaffApplicationDiscord;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.mail.StaffApplicationMail;
import com.alttd.altitudeweb.setup.Connection;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@AllArgsConstructor
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class ApplicationController implements ApplicationsApi {
private final AuthenticatedUuid authenticatedUuid;
private final StaffApplicationDataMapper staffApplicationDataMapper;
private final StaffApplicationMail staffApplicationMail;
private final StaffApplicationDiscord staffApplicationDiscord;
private final Instant open = Instant.parse("2025-10-18T00:00:00Z");
private final Instant close = Instant.parse("2025-10-26T00:00:00Z");
@Override
public ResponseEntity<Boolean> getStaffApplicationsIsOpen() {
return ResponseEntity.ok(isOpen());
}
private boolean isOpen() {
Instant now = Instant.now();
return !now.isBefore(open) && !now.isAfter(close);
}
@Override
public ResponseEntity<FormResponseDto> submitStaffApplication(StaffApplicationDto staffApplicationDto) {
if (!isOpen()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
UUID userUuid = authenticatedUuid.getAuthenticatedUserUuid();
String email = staffApplicationDto.getEmail() == null ? null : staffApplicationDto.getEmail().toLowerCase();
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, email);
if (optionalEmail.isEmpty() || !optionalEmail.get().verified()) {
log.warn("User {} attempted to submit an application without a verified email {}", userUuid, email);
return ResponseEntity.badRequest().build();
}
// Map and persist application
StaffApplication application = staffApplicationDataMapper.map(userUuid, staffApplicationDto);
saveApplication(application);
String username = getUsername(userUuid);
try {
if (!staffApplicationMail.sendApplicationEmail(username, application)) {
log.warn("Failed to send staff application email for {}", application.id());
return ResponseEntity.internalServerError().build();
}
} catch (Exception e) {
log.error("Error while sending staff application email for {}", application.id(), e);
return ResponseEntity.internalServerError().build();
}
try {
staffApplicationDiscord.sendApplicationToDiscord(username, application);
} catch (Exception e) {
log.error("Failed to send staff application {} to Discord", application.id(), e);
return ResponseEntity.internalServerError().build();
}
try {
markAsSent(application.id());
} catch (Exception e) {
log.error("Failed to mark application {} as sent", application.id(), e);
return ResponseEntity.internalServerError().build();
}
FormResponseDto response = buildResponse(application);
return ResponseEntity.status(200).body(response);
}
private void saveApplication(StaffApplication application) {
CompletableFuture<Void> saveFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
try {
sqlSession.getMapper(StaffApplicationMapper.class).insert(application);
saveFuture.complete(null);
} catch (Exception e) {
log.error("Failed to insert staff application", e);
saveFuture.completeExceptionally(e);
}
});
saveFuture.join();
}
private Optional<EmailVerification> fetchEmailVerification(UUID userUuid, String email) {
CompletableFuture<Optional<EmailVerification>> emailVerificationFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class)
.findByUserAndEmail(userUuid, email);
emailVerificationFuture.complete(Optional.ofNullable(verifiedMail));
});
return emailVerificationFuture.join();
}
private void markAsSent(UUID applicationId) {
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> sqlSession.getMapper(StaffApplicationMapper.class).markAsSent(applicationId));
}
private FormResponseDto buildResponse(StaffApplication application) {
String message = "Your staff application has been submitted. You will be notified when it has been reviewed.";
return new FormResponseDto(
application.id().toString(),
message,
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();
}
}
@@ -0,0 +1,104 @@
package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.MailApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.model.MailResponseDto;
import com.alttd.altitudeweb.model.SubmitEmailDto;
import com.alttd.altitudeweb.model.VerifyCodeDto;
import com.alttd.altitudeweb.model.EmailEntryDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.mail.MailVerificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RequiredArgsConstructor
@RateLimit(limit = 60, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class MailController implements MailApi {
private final MailVerificationService mailVerificationService;
private final AuthenticatedUuid authenticatedUuid;
@Override
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit")
public ResponseEntity<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream()
.filter(EmailVerification::verified)
.map(EmailVerification::email)
.anyMatch(mail -> mail.equalsIgnoreCase(submitEmailDto.getEmail()));
if (emailAlreadyVerified) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email already verified for user");
}
EmailVerification saved = mailVerificationService.submitEmail(uuid, submitEmailDto.getEmail());
MailResponseDto response = new MailResponseDto()
.email(saved.email())
.message("Verification email sent")
.verified(false);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@Override
@RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify")
public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode());
if (optionalEmailVerification.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code");
}
EmailVerification emailVerification = optionalEmailVerification.get();
MailResponseDto response = new MailResponseDto()
.email(emailVerification.email())
.message("Email verified successfully")
.verified(true);
return ResponseEntity.ok(response);
}
@Override
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend")
public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
EmailVerification updated = mailVerificationService.resend(uuid, submitEmailDto.getEmail());
if (updated == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
}
MailResponseDto response = new MailResponseDto()
.email(updated.email())
.message("Verification email resent")
.verified(false);
return ResponseEntity.ok(response);
}
@Override
@RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete")
public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail());
if (!deleted) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
}
return ResponseEntity.noContent().build();
}
@Override
public ResponseEntity<List<EmailEntryDto>> getUserEmails() {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
List<EmailVerification> emails = mailVerificationService.listAll(uuid);
List<EmailEntryDto> result = emails.stream()
.map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified()))
.toList();
return ResponseEntity.ok(result);
}
}
@@ -1,16 +1,18 @@
package com.alttd.altitudeweb.controllers.history;
import com.alttd.altitudeweb.api.HistoryApi;
import com.alttd.altitudeweb.controllers.limits.RateLimit;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.model.HistoryCountDto;
import com.alttd.altitudeweb.model.PunishmentHistoryListDto;
import com.alttd.altitudeweb.setup.Connection;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.litebans.*;
import com.alttd.altitudeweb.model.PunishmentHistoryDto;
import com.alttd.altitudeweb.model.PunishmentHistoryInnerDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@@ -19,19 +21,22 @@ import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.MINUTES)
@RateLimit(limit = 30, timeValue = 10, timeUnit = TimeUnit.SECONDS)
@RequiredArgsConstructor
public class HistoryApiController implements HistoryApi {
private final AuthenticatedUuid authenticatedUuid;
@Override
public ResponseEntity<PunishmentHistoryDto> getHistoryForAll(String userType, String type, Integer page) {
public ResponseEntity<PunishmentHistoryListDto> getHistoryForAll(String userType, String type, Integer page) {
return getHistoryForUsers(userType, type, "", page);
}
@Override
public ResponseEntity<PunishmentHistoryDto> getHistoryForUsers(String userType, String type, String user, Integer page) {
public ResponseEntity<PunishmentHistoryListDto> getHistoryForUsers(String userType, String type, String user, Integer page) {
UserType userTypeEnum = UserType.getUserType(userType);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
PunishmentHistoryDto punishmentHistory = new PunishmentHistoryDto();
PunishmentHistoryListDto punishmentHistoryList = new PunishmentHistoryListDto();
CompletableFuture<List<HistoryRecord>> historyRecords = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
@@ -46,14 +51,14 @@ public class HistoryApiController implements HistoryApi {
historyRecords.completeExceptionally(e);
}
});
return mapPunishmentHistory(punishmentHistory, historyRecords);
return mapPunishmentHistory(punishmentHistoryList, historyRecords);
}
@Override
public ResponseEntity<PunishmentHistoryDto> getHistoryForUuid(String userType, String type, String uuid, Integer page) {
public ResponseEntity<PunishmentHistoryListDto> getHistoryForUuid(String userType, String type, String uuid, Integer page) {
UserType userTypeEnum = UserType.getUserType(userType);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
PunishmentHistoryDto punishmentHistory = new PunishmentHistoryDto();
PunishmentHistoryListDto punishmentHistoryList = new PunishmentHistoryListDto();
CompletableFuture<List<HistoryRecord>> historyRecords = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
@@ -68,7 +73,7 @@ public class HistoryApiController implements HistoryApi {
historyRecords.completeExceptionally(e);
}
});
return mapPunishmentHistory(punishmentHistory, historyRecords);
return mapPunishmentHistory(punishmentHistoryList, historyRecords);
}
@Override
@@ -153,6 +158,44 @@ public class HistoryApiController implements HistoryApi {
return ResponseEntity.ok().body(searchResultCountCompletableFuture.join());
}
@Override
public ResponseEntity<PunishmentHistoryListDto> getAllHistoryForUUID(String uuid) {
PunishmentHistoryListDto punishmentHistoryList = new PunishmentHistoryListDto();
CompletableFuture<List<HistoryRecord>> historyRecordsCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
log.debug("Loading all history for uuid {}", uuid);
try {
List<HistoryRecord> punishments = sqlSession.getMapper(UUIDHistoryMapper.class)
.getAllHistoryForUUID(UUID.fromString(uuid));
historyRecordsCompletableFuture.complete(punishments);
} catch (Exception e) {
log.error("Failed to load all history for uuid {}", uuid, e);
historyRecordsCompletableFuture.completeExceptionally(e);
}
});
return mapPunishmentHistory(punishmentHistoryList, historyRecordsCompletableFuture);
}
@Override
public ResponseEntity<PunishmentHistoryDto> getHistoryById(String type, Integer id) {
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<HistoryRecord> historyRecordCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
.runQuery(sqlSession -> {
log.debug("Loading history by id");
try {
HistoryRecord punishment = sqlSession.getMapper(IdHistoryMapper.class)
.getRecentHistory(historyTypeEnum, id);
historyRecordCompletableFuture.complete(punishment);
} catch (Exception e) {
log.error("Failed to load history count", e);
historyRecordCompletableFuture.completeExceptionally(e);
}
});
return ResponseEntity.ok().body(mapPunishmentHistory(historyRecordCompletableFuture.join()));
}
private ResponseEntity<HistoryCountDto> mapHistoryCount(HistoryCount historyCount) {
HistoryCountDto historyCountDto = new HistoryCountDto();
historyCountDto.setBans(historyCount.getBans());
@@ -162,28 +205,140 @@ public class HistoryApiController implements HistoryApi {
return ResponseEntity.ok().body(historyCountDto);
}
private ResponseEntity<PunishmentHistoryDto> mapPunishmentHistory(PunishmentHistoryDto punishmentHistory, CompletableFuture<List<HistoryRecord>> historyRecords) {
private ResponseEntity<PunishmentHistoryListDto> mapPunishmentHistory(PunishmentHistoryListDto punishmentHistoryList, CompletableFuture<List<HistoryRecord>> historyRecords) {
historyRecords.join().forEach(historyRecord -> {
PunishmentHistoryInnerDto.TypeEnum type = switch (historyRecord.getType().toLowerCase()) {
case "ban" -> PunishmentHistoryInnerDto.TypeEnum.BAN;
case "mute" -> PunishmentHistoryInnerDto.TypeEnum.MUTE;
case "warn" -> PunishmentHistoryInnerDto.TypeEnum.WARN;
case "kick" -> PunishmentHistoryInnerDto.TypeEnum.KICK;
default -> throw new IllegalStateException("Unexpected value: " + historyRecord.getType());
};
PunishmentHistoryInnerDto innerDto = new PunishmentHistoryInnerDto()
.uuid(historyRecord.getUuid())
.username(historyRecord.getPunishedName())
.reason(historyRecord.getReason())
.punishedByUuid(historyRecord.getBannedByUuid())
.punishedBy(historyRecord.getBannedByName())
.removedBy(historyRecord.getRemovedByName())
.punishmentTime(historyRecord.getTime())
.expiryTime(historyRecord.getUntil())
.removedReason(historyRecord.getRemovedByReason())
.type(type);
punishmentHistory.add(innerDto);
PunishmentHistoryDto innerDto = mapPunishmentHistory(historyRecord);
punishmentHistoryList.add(innerDto);
});
return ResponseEntity.ok().body(punishmentHistory);
return ResponseEntity.ok().body(punishmentHistoryList);
}
private static PunishmentHistoryDto mapPunishmentHistory(HistoryRecord historyRecord) {
PunishmentHistoryDto.TypeEnum type = switch (historyRecord.getType().toLowerCase()) {
case "ban" -> PunishmentHistoryDto.TypeEnum.BAN;
case "mute" -> PunishmentHistoryDto.TypeEnum.MUTE;
case "warn" -> PunishmentHistoryDto.TypeEnum.WARN;
case "kick" -> PunishmentHistoryDto.TypeEnum.KICK;
default -> throw new IllegalStateException("Unexpected value: " + historyRecord.getType());
};
return new PunishmentHistoryDto()
.uuid(historyRecord.getUuid())
.username(historyRecord.getPunishedName())
.reason(historyRecord.getReason())
.punishedByUuid(historyRecord.getBannedByUuid())
.punishedBy(historyRecord.getBannedByName())
.removedBy(historyRecord.getRemovedByName())
.punishmentTime(historyRecord.getTime())
.expiryTime(historyRecord.getUntil())
.removedReason(historyRecord.getRemovedByReason())
.type(type)
.id(historyRecord.getId());
}
@Override
public ResponseEntity<PunishmentHistoryDto> updatePunishmentReason(String type, Integer id, String reason) {
log.debug("Updating reason for {} id {} to {}", type, id, reason);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
final UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
try {
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
if (before == null) {
result.complete(null);
return;
}
int changed = editMapper.setReason(historyTypeEnum, id, reason);
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
result.complete(after != null ? mapPunishmentHistory(after) : null);
} catch (Exception e) {
log.error("Failed to update reason for {} id {}", type, id, e);
result.completeExceptionally(e);
}
});
PunishmentHistoryDto body = result.join();
if (body == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(body);
}
@Override
public ResponseEntity<PunishmentHistoryDto> updatePunishmentUntil(String type, Integer id, Long until) {
log.debug("Updating until for {} id {} to {}", type, id, until);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
final UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
try {
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
if (before == null) {
result.complete(null);
return;
}
int changed = editMapper.setUntil(historyTypeEnum, id, until);
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
result.complete(after != null ? mapPunishmentHistory(after) : null);
} catch (IllegalArgumentException e) {
log.warn("Invalid until edit for type {} id {}: {}", type, id, e.getMessage());
result.complete(null);
} catch (Exception e) {
log.error("Failed to update until for {} id {}", type, id, e);
result.completeExceptionally(e);
}
});
PunishmentHistoryDto body = result.join();
if (body == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(body);
}
@Override
public ResponseEntity<Void> removePunishment(String type, Integer id) {
log.debug("Removing punishment for {} id {}", type, id);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<Boolean> result = new CompletableFuture<>();
final UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
try {
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
if (before == null) {
result.complete(false);
return;
}
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
int changed = editMapper.remove(historyTypeEnum, id);
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",
actorName, actorUuid, historyTypeEnum, id,
before.getRemovedByName() == null ? 1 : 0, before.getRemovedByName(), before.getRemovedByReason(),
changed);
result.complete(changed > 0);
} catch (Exception e) {
log.error("Failed to remove punishment for {} id {}", type, id, e);
result.completeExceptionally(e);
}
});
Boolean ok = result.join();
if (ok == null || !ok) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,102 @@
package com.alttd.altitudeweb.controllers.login;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.KeyPairEntity;
import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.concurrent.CompletableFuture;
@Service
@RequiredArgsConstructor
@Slf4j
public class KeyPairService {
private KeyPair cachedKeyPair = null;
private static final String RSA_ALGORITHM = "RSA";
private static final int RSA_KEY_SIZE = 2048;
public KeyPair getJwtSigningKeyPair() {
if (cachedKeyPair != null) {
return cachedKeyPair;
}
KeyPair keyPair = getOrCreateKeyPair();
if (keyPair != null) {
cachedKeyPair = keyPair;
return cachedKeyPair;
}
throw new IllegalStateException("Failed to generate or load key pair");
}
public KeyPair getOrCreateKeyPair() {
CompletableFuture<KeyPairEntity> keyPairFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Loading key pair");
try {
KeyPairEntity entity = sqlSession.getMapper(KeyPairMapper.class).getKeyPair();
keyPairFuture.complete(entity);
} catch (Exception e) {
log.error("Failed to key pair", e);
keyPairFuture.completeExceptionally(e);
}
});
KeyPairEntity keyPairEntity = keyPairFuture.join();
if (keyPairEntity != null) {
try {
byte[] privateKeyBytes = Base64.getDecoder().decode(keyPairEntity.getPrivateKey());
byte[] publicKeyBytes = Base64.getDecoder().decode(keyPairEntity.getPublicKey());
KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM);
PrivateKey privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes));
PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes));
return new KeyPair(publicKey, privateKey);
} catch (Exception e) {
log.error("Failed to load key pair from database", e);
}
}
KeyPair keyPair = generateKeyPair();
try {
KeyPairEntity entity = new KeyPairEntity();
entity.setPrivateKey(Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()));
entity.setPublicKey(Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()));
entity.setCreatedAt(Instant.now());
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Saving key pair");
try {
sqlSession.getMapper(KeyPairMapper.class).save(entity);
log.info("Generated and saved new key pair");
} catch (Exception e) {
log.error("Failed to key pair", e);
}
});
} catch (Exception e) {
log.error("Failed to save key pair to database", e);
}
return keyPair;
}
private KeyPair generateKeyPair() {
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(RSA_ALGORITHM);
keyPairGenerator.initialize(RSA_KEY_SIZE);
return keyPairGenerator.generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Error generating key pair", e);
}
}
}
@@ -0,0 +1,252 @@
package com.alttd.altitudeweb.controllers.login;
import com.alttd.altitudeweb.api.LoginApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.litebans.RecentNamesMapper;
import com.alttd.altitudeweb.database.web_db.PrivilegedUser;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.model.UsernameDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
@Slf4j
@RequiredArgsConstructor
@EnableScheduling
@RestController
public class LoginController implements LoginApi {
private final JwtEncoder jwtEncoder;
private final AuthenticatedUuid authenticatedUuid;
@Value("${login.secret:#{null}}")
private String loginSecret;
@Value("${my-server.address:#{null}}")
private String serverAddress;
private record CacheEntry(UUID uuid, Instant expiry) {
}
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
@Scheduled(fixedRate = 300000) // 5 minutes in milliseconds
protected void clearExpiredCacheEntries() {
Instant now = Instant.now();
int initialCacheSize = cache.size();
cache.entrySet().removeIf(entry -> entry.getValue().expiry().isBefore(now));
log.info("Cleared {} expired cache entries", initialCacheSize - cache.size());
}
@RateLimit(limit = 100, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "addLogin")
@Override
public ResponseEntity<String> requestLogin(String authorization, String uuid) {
UUID uuidFromString;
try {
uuidFromString = UUID.fromString(uuid);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
log.info("{} is requesting a login code", uuid);
if (authorization == null || !authorization.startsWith("SECRET ")) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
String secret = authorization.substring("SECRET ".length());
if (!isValidSecret(secret)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid secret");
}
Optional<String> key = cache.entrySet().stream()
.filter(entry -> entry.getValue().uuid.equals(uuidFromString))
.map(Map.Entry::getKey)
.findFirst();
if (key.isPresent()) {
log.info("{} got cached key: {}", uuid, key.get());
return ResponseEntity.ok(key.get());
}
String loginCode = generateLoginCode(uuidFromString);
log.info("{} received login code: {}", uuid, loginCode);
return ResponseEntity.ok(loginCode);
}
@Override
public ResponseEntity<UsernameDto> getUsername() {
log.debug("Loading username for logged in user");
try {
// Get authenticated UUID using the utility method
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
log.debug("Loaded username for logged in user {}", uuid);
// Create response with username
UsernameDto usernameDto = new UsernameDto();
usernameDto.setUsername(getUsername(uuid));
log.debug("Loaded username for logged in user {}", usernameDto.getUsername());
return ResponseEntity.ok(usernameDto);
} catch (ResponseStatusException e) {
// The utility method already throws proper exceptions, we just need to convert them to ResponseEntity
return ResponseEntity.status(e.getStatusCode()).build();
}
}
private String getUsername(UUID uuid) {
CompletableFuture<String> username = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
.runQuery(sqlSession -> {
log.debug("Loading all history through logged in uuid");
try {
String temp = sqlSession
.getMapper(RecentNamesMapper.class)
.getUsername(uuid.toString());
username.complete(temp);
} catch (Exception e) {
log.error("Failed to find username for uuid {}", uuid, e);
username.completeExceptionally(e);
}
});
return username.join();
}
@Value("${UNSECURED:#{false}}")
private boolean unsecured;
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
@Override
public ResponseEntity<String> login(String code) {
if (unsecured) {
log.warn("Unsecured login is enabled, skipping login validation!");
} else {
log.info("Received login request with code {}", code);
}
if (code == null) {
log.warn("Received null login code");
return ResponseEntity.badRequest().build();
}
CacheEntry cacheEntry = cache.get(code);
if (!unsecured && (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now()))) {
log.warn("Received invalid login code {}", code);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (unsecured && cacheEntry == null) {
cacheEntry = new CacheEntry(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"), Instant.now().plusSeconds(TimeUnit.DAYS.toSeconds(1)));
}
String token = generateToken(cacheEntry.uuid);
log.debug("Generated token for user {} with token {}", cacheEntry.uuid, token);
cache.remove(code);
log.debug("Generated token for user {}", cacheEntry.uuid);
return ResponseEntity.ok(token);
}
private String generateLoginCode(UUID uuid) {
String characters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
StringBuilder loginCode = new StringBuilder();
for (int i = 0; i < 8; i++) {
int index = (int) (Math.random() * characters.length());
loginCode.append(characters.charAt(index));
}
CacheEntry cacheEntry = new CacheEntry(uuid,
Instant.now().plusSeconds(TimeUnit.MINUTES.toSeconds(15)));
cache.put(loginCode.toString(), cacheEntry);
return loginCode.toString();
}
private boolean isValidSecret(String secret) {
if (loginSecret == null) {
log.warn("No login secret set, skipping secret validation");
return false;
}
if (loginSecret.length() < 16) {
log.warn("Login secret is too short, skipping secret validation");
return false;
}
if (!loginSecret.equals(secret)) {
log.info("Received invalid secret attempt");
return false;
}
return true;
}
private String generateToken(UUID uuid) {
Instant now = Instant.now();
//TODO make a JWT for renewing and one for storing permissions for a session (expiry 1 hour)
Instant expiryTime = now.plusSeconds(TimeUnit.DAYS.toSeconds(30));
CompletableFuture<Optional<PrivilegedUser>> privilegedUserCompletableFuture = new CompletableFuture<>();
List<PermissionClaimDto> claimList = new ArrayList<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
try {
log.debug("Loading user by uuid {}", uuid.toString());
PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class);
Optional<PrivilegedUser> optionalPrivilegedUser = mapper
.getUserByUuid(uuid);
if (optionalPrivilegedUser.isEmpty()) {
PrivilegedUser privilegedUser = new PrivilegedUser(null, uuid, List.of());
mapper.createPrivilegedUser(privilegedUser);
privilegedUserCompletableFuture.complete(
Optional.of(privilegedUser));
} else {
privilegedUserCompletableFuture.complete(optionalPrivilegedUser);
}
} catch (Exception e) {
log.error("Failed to load user by uuid", e);
privilegedUserCompletableFuture.completeExceptionally(e);
}
});
Optional<PrivilegedUser> privilegedUser = privilegedUserCompletableFuture.join();
claimList.add(PermissionClaimDto.USER);
privilegedUser.ifPresent(user -> user.getPermissions().forEach(permission -> {
try {
claimList.add(PermissionClaimDto.fromValue(permission));
log.debug("Added permission claim {}", permission);
} catch (IllegalArgumentException e) {
log.warn("Received invalid permission claim: {}", permission);
}
}));
log.debug("Generated token for user {} with claims {}", uuid.toString(),
claimList.stream().map(PermissionClaimDto::getValue).toList());
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(serverAddress)
.claim("authorities",
claimList.stream().map(PermissionClaimDto::getValue).toList())
.issuedAt(now)
.expiresAt(expiryTime)
.subject(uuid.toString())
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
}
@@ -0,0 +1,141 @@
package com.alttd.altitudeweb.controllers.particles;
import com.alttd.altitudeweb.api.ParticlesApi;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@Slf4j
@RequiredArgsConstructor
@RestController
public class ParticleController implements ParticlesApi {
// @Value("${login.secret:#{null}}")
// private String loginSecret;
@Value("${particles.file_path}")
private String particlesFilePath;
// @Value("${notification.server.url:http://localhost:8080}")
// private String notificationServerUrl;
@Override
public ResponseEntity<Resource> downloadFile(String filename) {
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not downloading particles file", particlesFilePath);
return ResponseEntity.status(404).build();
}
return getFileForDownload(file, filename);
}
@Override
public ResponseEntity<Resource> downloadFileForUser(String uuid, String filename) {
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not downloading particles user file", particlesFilePath);
return ResponseEntity.status(404).build();
}
File targetDir = new File(file, uuid);
if (targetDir.exists()) {
return getFileForDownload(targetDir, filename);
} else {
log.warn("User {} does not have a directory for particles files", uuid);
return ResponseEntity.notFound().build();
}
}
private ResponseEntity<Resource> getFileForDownload(File file, String filename) {
filename += ".json";
File targetFile = new File(file, filename);
if (!targetFile.exists()) {
log.warn("Particles file {} does not exist", targetFile.getAbsolutePath());
return ResponseEntity.notFound().build();
}
if (!targetFile.isFile()) {
log.warn("Particles file {} is not a file", targetFile.getAbsolutePath());
return ResponseEntity.status(404).build();
}
try {
Path path = targetFile.toPath();
ByteArrayResource resource = new ByteArrayResource(Files.readAllBytes(path));
return ResponseEntity.ok()
.contentLength(targetFile.length())
.contentType(MediaType.APPLICATION_JSON)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.body(resource);
} catch (IOException e) {
log.error("Failed to read particles file {}: {}", targetFile.getAbsolutePath(), e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@Override
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) {
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not saving particles file", particlesFilePath);
return ResponseEntity.status(404).build();
}
return writeContentToFile(file, filename, content);
}
@Override
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) {
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not saving particles user file", particlesFilePath);
return ResponseEntity.status(404).build();
}
File targetDir = new File(file, uuid);
if (!file.exists()) {
log.debug("Creating particles directory {}", targetDir.getAbsolutePath());
if (targetDir.mkdirs()) {
log.info("Created particles user directory {}", targetDir.getAbsolutePath());
}
}
return writeContentToFile(file, filename, content);
}
private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) {
filename += ".json";
File targetFile = new File(dir, filename);
if (!Files.isWritable(targetFile.toPath())) {
log.error("Particles file {} is not writable", targetFile.getAbsolutePath());
return ResponseEntity.status(403).build();
}
if (targetFile.exists()) {
log.warn("Overwriting existing particles file {}", targetFile.getAbsolutePath());
}
try {
content.transferTo(targetFile);
} catch (Exception e) {
log.error("Failed to write particles file {}", targetFile.getAbsolutePath(), e);
return ResponseEntity.status(500).build();
}
return ResponseEntity.ok().build();
}
}
@@ -0,0 +1,57 @@
package com.alttd.altitudeweb.controllers.site;
import com.alttd.altitudeweb.api.SiteApi;
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.VoteStatsDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.site.StaffPtService;
import com.alttd.altitudeweb.services.site.VoteService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
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.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RequiredArgsConstructor
@RateLimit(limit = 2, timeValue = 10, timeUnit = TimeUnit.SECONDS)
public class SiteController implements SiteApi {
private final VoteService voteService;
private final AuthenticatedUuid authenticatedUuid;
private final StaffPtService staffPtService;
@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() {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid);
if (optionalVoteDataDto.isEmpty()) {
return ResponseEntity.noContent().build();
}
VoteDataDto voteDataDto = optionalVoteDataDto.get();
return ResponseEntity.ok(voteDataDto);
}
}
@@ -1,7 +1,7 @@
package com.alttd.altitudeweb.controllers.team;
import com.alttd.altitudeweb.api.TeamApi;
import com.alttd.altitudeweb.controllers.limits.RateLimit;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.setup.Connection;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.luckperms.Player;
@@ -0,0 +1,35 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import com.alttd.altitudeweb.model.MinecraftAppealDto;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.UUID;
@Service
public class AppealDataMapper {
public MinecraftAppealDto appealToMinecraftAppealDto(Appeal appeal) {
MinecraftAppealDto minecraftAppealDto = new MinecraftAppealDto();
minecraftAppealDto.setAppeal(appeal.reason());
minecraftAppealDto.setUsername(appeal.username());
minecraftAppealDto.setUuid(appeal.uuid());
minecraftAppealDto.setEmail(appeal.email());
return minecraftAppealDto;
}
public Appeal minecraftAppealDtoToAppeal(MinecraftAppealDto minecraftAppealDto) {
return new Appeal(
UUID.randomUUID(),
minecraftAppealDto.getUuid(),
minecraftAppealDto.getPunishmentType().toString(),
minecraftAppealDto.getPunishmentId(),
minecraftAppealDto.getUsername(),
minecraftAppealDto.getAppeal(),
Instant.now(),
null,
minecraftAppealDto.getEmail(),
null
);
}
}
@@ -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,53 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import com.alttd.altitudeweb.model.StaffApplicationDto;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class StaffApplicationDataMapper {
/**
* Maps the incoming DTO and the authenticated user's UUID to a StaffApplication entity.
* Normalizes and prepares fields as needed (lowercase email, join availableDays, timestamps, ids).
*/
public StaffApplication map(UUID userUuid, StaffApplicationDto dto) {
String email = dto.getEmail() == null ? null : dto.getEmail().toLowerCase();
String availableDaysJoined = joinList(dto.getAvailableDays());
return new StaffApplication(
UUID.randomUUID(),
userUuid,
email,
dto.getAge(),
dto.getDiscordUsername(),
Boolean.TRUE.equals(dto.getMeetsRequirements()),
dto.getPronouns(),
dto.getJoinDate(),
dto.getWeeklyPlaytime(),
availableDaysJoined,
dto.getAvailableTimes(),
dto.getPreviousExperience(),
dto.getPluginExperience(),
dto.getModeratorExpectations(),
dto.getAdditionalInfo(),
Instant.now(),
null,
null
);
}
private String joinList(List<String> list) {
if (list == null) return null;
// Avoid NPEs and trim entries
return list.stream()
.filter(s -> s != null && !s.isBlank())
.map(String::trim)
.collect(Collectors.joining(","));
}
}
@@ -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,62 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow;
import com.alttd.altitudeweb.model.VoteDataDto;
import com.alttd.altitudeweb.model.VoteInfoDto;
import com.alttd.altitudeweb.model.VoteStatsDto;
import com.alttd.altitudeweb.model.VoteStreakDto;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
@Service
public class VotingStatsRowToVoteDataDto {
public VoteDataDto map(VotingStatsRow votingStatsRow) {
VoteDataDto voteDataDto = new VoteDataDto();
voteDataDto.setVoteStats(getVoteStats(votingStatsRow));
voteDataDto.setVoteStreak(getVoteStreak(votingStatsRow));
voteDataDto.setBestVoteStreak(getBestVoteStreak(votingStatsRow));
voteDataDto.setAllVoteInfo(getVoteInfo(votingStatsRow.lastVotes()));
return voteDataDto;
}
private VoteStreakDto getVoteStreak(VotingStatsRow votingStatsRow) {
VoteStreakDto voteStreakDto = new VoteStreakDto();
voteStreakDto.setDailyStreak(votingStatsRow.dailyStreak());
voteStreakDto.setWeeklyStreak(votingStatsRow.weeklyStreak());
voteStreakDto.setMonthlyStreak(votingStatsRow.monthlyStreak());
return voteStreakDto;
}
private VoteStreakDto getBestVoteStreak(VotingStatsRow votingStatsRow) {
VoteStreakDto voteStreakDto = new VoteStreakDto();
voteStreakDto.setDailyStreak(votingStatsRow.bestDailyStreak());
voteStreakDto.setWeeklyStreak(votingStatsRow.bestWeeklyStreak());
voteStreakDto.setMonthlyStreak(votingStatsRow.bestMonthlyStreak());
return voteStreakDto;
}
private VoteStatsDto getVoteStats(VotingStatsRow votingStatsRow) {
VoteStatsDto voteStatsDto = new VoteStatsDto();
voteStatsDto.setDaily(votingStatsRow.totalVotesToday());
voteStatsDto.setWeekly(votingStatsRow.totalVotesThisWeek());
voteStatsDto.setMonthly(votingStatsRow.totalVotesThisMonth());
voteStatsDto.setTotal(votingStatsRow.totalVotesAllTime());
return voteStatsDto;
}
private List<VoteInfoDto> getVoteInfo(String lastVotes) {
return Arrays.stream(lastVotes.split("%line%"))
.map(voteInfo -> {
String[] siteAndTimestamp = voteInfo.split("//");
VoteInfoDto voteInfoDto = new VoteInfoDto();
voteInfoDto.setSiteName(siteAndTimestamp[0]);
voteInfoDto.setLastVoteTimestamp(Long.parseLong(siteAndTimestamp[1]));
return voteInfoDto;
})
.toList();
}
}
@@ -0,0 +1,44 @@
package com.alttd.altitudeweb.services.chat;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.chat.NicknameMapper;
import com.alttd.altitudeweb.database.chat.PartyMapper;
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
import com.alttd.altitudeweb.model.PlayerNameDto;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatInfoService {
public Optional<PlayerNameDto> getPlayerName(UUID uuid) {
String username = Connection.getConnection(Databases.LUCK_PERMS)
.runQueryWithResult(sqlSession -> sqlSession.getMapper(UUIDUsernameMapper.class).getUsernameFromUUID(uuid.toString()));
if (username == null) {
return Optional.empty();
}
Optional<String> nickname = Connection.getConnection(Databases.CHAT)
.runQueryWithResult(sqlSession -> sqlSession.getMapper(NicknameMapper.class).getNicknameFromUUID(uuid.toString()));
PlayerNameDto playerNameDto = new PlayerNameDto();
playerNameDto.setName(username);
playerNameDto.setNickname(nickname.orElse(null));
return Optional.of(playerNameDto);
}
public Optional<String> getPartyName(int id) {
return Connection.getConnection(Databases.CHAT)
.runQueryWithResult(sqlSession -> sqlSession.getMapper(PartyMapper.class).getPartyName(id));
}
}
@@ -0,0 +1,263 @@
package com.alttd.altitudeweb.services.chat;
import com.alttd.altitudeweb.controllers.chat.ChatMessage;
import com.alttd.altitudeweb.controllers.chat.ChatMessageMapper;
import com.alttd.altitudeweb.controllers.chat.ServerStateMapper;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.chat.ChatLogMapper;
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSession;
import com.alttd.altitudeweb.database.web_db.chat_session.ChatSessionMapper;
import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.model.ServerDto;
import com.alttd.altitudeweb.model.ServerStateDto;
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
import com.alttd.altitudeweb.services.chat.event_publisher.MessageForUser;
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
import com.alttd.altitudeweb.services.chat.to_server.mappers.PlayerListStateMapper;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
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.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {
private static final Duration MAX_AGE = Duration.ofHours(1);
private final EventPublisher eventPublisher;
private final ServerMessageService serverMessageService;
private final NavigableMap<Instant, ChatMessage> chatMessages = new TreeMap<>();
private final Map<String, EventUser> eventUserMap = new ConcurrentHashMap<>();
private final Set<UUID> hiddenUsers = ConcurrentHashMap.newKeySet();
private final Map<String, ServerDto> serverStateCache = new HashMap<>();
@Value("${chat.allowed-servers}")
private String[] allowedServers;
private long lastMillis = 0;
@Scheduled(cron = "0 * * * * *")
public void clearOldMessages() {
Instant cutoff = Instant.now().minus(MAX_AGE);
int currentSize = chatMessages.size();
chatMessages.headMap(cutoff, false).clear();
log.info("Clearing old chat messages. Removed {}/{} messages",
currentSize - chatMessages.size(), currentSize);
}
@EventListener(ApplicationReadyEvent.class)
public void loadMessagesFromDb() {
Connection.getConnection(Databases.CHAT)
.runQuery(sqlSession -> {
log.debug("Loading uuid search result count");
try {
sqlSession.getMapper(ChatLogMapper.class)
.getChatLogs(Instant.now().minus(Duration.ofHours(1)).toEpochMilli())
.stream()
.map(ChatMessageMapper::fromDao)
.forEach(this::putChatMessage);
} catch (Exception e) {
log.error("Failed to load history count", e);
}
});
}
public synchronized SseEmitter subscribe(EventUser eventUser, String json) {
String key = eventUser.uuid().toString();
boolean newUser = eventUserMap.put(key, eventUser) == null;
SseEmitter emitter = eventPublisher.subscribe(key, json, this::handleSessionEnd);
if (newUser) {
sendPlayerStateToServers();
}
sendServerStateToUser(key, eventUser);
return emitter;
}
private synchronized void handleSessionEnd(String key, Instant sessionStart, Instant sessionEnd) {
EventUser eventUser = eventUserMap.get(key);
if (eventUser == null) {
log.error("Failed to find event user for key {}", key);
return;
}
ChatSession chatSession = ChatSession.builder()
.uuid(eventUser.uuid())
.session_start(sessionStart)
.session_end(sessionEnd)
.build();
saveSession(chatSession);
if (!eventPublisher.hasSubscribers(key) && eventUserMap.remove(key, eventUser)) {
hiddenUsers.remove(eventUser.uuid());
sendPlayerStateToServers();
}
}
public synchronized void hideUserFromPlayerList(UUID uuid) {
requireActiveHeadMod(uuid);
if (hiddenUsers.add(uuid)) {
sendPlayerStateToServers();
}
}
public synchronized void showUserInPlayerList(UUID uuid) {
requireActiveHeadMod(uuid);
if (hiddenUsers.remove(uuid)) {
sendPlayerStateToServers();
}
}
public synchronized boolean isUserHiddenFromPlayerList(UUID uuid) {
requireActiveHeadMod(uuid);
return hiddenUsers.contains(uuid);
}
private void requireActiveHeadMod(UUID uuid) {
EventUser eventUser = eventUserMap.get(uuid.toString());
if (eventUser == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "No active chat session");
}
if (!eventUser.hasPermission(PermissionClaimDto.HEAD_MOD)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only head moderators can change session visibility");
}
}
private void sendPlayerStateToServers() {
ArrayList<UUID> activePlayers = eventUserMap.values().stream()
.map(EventUser::uuid)
.filter(uuid -> !hiddenUsers.contains(uuid))
.collect(Collectors.toCollection(ArrayList::new));
serverMessageService.sendMessageToAll("player_state", PlayerListStateMapper.fromList(activePlayers));
}
private void saveSession(ChatSession chatSession) {
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Saving chat session");
try {
sqlSession.getMapper(ChatSessionMapper.class).storeSession(chatSession);
log.debug("Saved chat session");
} catch (Exception e) {
log.error("Failed to save chat session", e);
}
});
}
private synchronized void setUniqueTimestamp(ChatMessage chatMessage) {
long timestamp = chatMessage.getTimestamp().toEpochMilli();
if (timestamp == lastMillis) {
timestamp += 1;
} else {
return;
}
lastMillis = timestamp;
chatMessage.setTimestamp(Instant.ofEpochMilli(timestamp));
}
private void putChatMessage(ChatMessage chatMessage) {
setUniqueTimestamp(chatMessage);
chatMessages.put(chatMessage.getTimestamp(), chatMessage);
}
public void addChatMessage(List<ChatMessage> chatMessageList) {
chatMessageList.forEach(this::putChatMessage);
sendMessagesToListeners(chatMessageList);
}
public synchronized void updateServerState(ServerStateDto serverStateDto) {
serverStateDto.getServers().forEach(serverDto -> serverStateCache.put(serverDto.getName(), serverDto));
sendToUsers("server-state", this::getServerStateJson);
}
private String getServerStateJson(EventUser eventUser) {
if (eventUser.hasPermission(PermissionClaimDto.HEAD_MOD)) {
return ServerStateMapper.toJson(new ServerStateDto().servers(serverStateCache.values().stream().toList()));
}
List<ServerDto> filteredServers = serverStateCache.values().stream()
.filter(serverDto -> Arrays.asList(allowedServers).contains(serverDto.getName()))
.toList();
return ServerStateMapper.toJson(new ServerStateDto().servers(filteredServers));
}
private void sendServerStateToUser(String key, EventUser eventUser) {
String json = getServerStateJson(eventUser);
eventPublisher.sendToUser(key, "server-state", json);
}
private void sendMessagesToListeners(List<ChatMessage> chatMessageList) {
Map<ChatMessage, String> jsonCache = new HashMap<>();
for (ChatMessage message : chatMessageList) {
jsonCache.put(message, ChatMessageMapper.toJson(message));
}
sendToUsers("chat", (eventUser) ->
chatMessageList.stream()
.filter(chatMessage -> shouldReceive(eventUser, chatMessage))
.map(jsonCache::get)
.collect(Collectors.joining(",", "[", "]"))
);
}
/**
* Broadcasts to every currently subscribed key. The message content for
* each key is resolved via messageForUser, which now works off the
* unique key itself rather than an EventUser instance.
*/
public void sendToUsers(String eventName, MessageForUser messageForUser) {
eventUserMap.forEach((key, eventUser) -> {
String json = messageForUser.get(eventUser);
eventPublisher.sendToUser(key, eventName, json);
});
}
private boolean shouldReceive(EventUser eventUser, ChatMessage chatMessage) {
if (eventUser.hasPermission(PermissionClaimDto.HEAD_MOD)) {
return true;
}
if (!Arrays.asList(allowedServers).contains(chatMessage.getServer())) {
return false;
}
if (!chatMessage.isNotBlocked()) {
return false;
}
return switch (chatMessage.getType()) {
case PUBLIC, GLOBAL -> true;
case PARTY -> {
Integer partyId = eventUser.partyId();
if (partyId == null) {
yield false;
}
yield chatMessage.getChannel().equals(String.valueOf(partyId));
}
case MSG -> eventUser.uuid().toString().equals(chatMessage.getReceiver());
case GAC -> eventUser.hasPermission(PermissionClaimDto.MOD);
case CUSTOM -> false;
};
}
public List<ChatMessage> getMessagesSince(Instant instant) {
return chatMessages.tailMap(instant, false).values().stream().toList();
}
}
@@ -0,0 +1,148 @@
package com.alttd.altitudeweb.services.chat.event_publisher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Generic SSE event publisher, keyed by an arbitrary "unique" string
* The same key may be used by several concurrent connections
* (e.g., a user/server with multiple open tabs or connections).
* Each subscribe() call adds its own emitter under that key
* without evicting the others.
*/
@RequiredArgsConstructor
@Service
@Slf4j
public class EventPublisher {
private final Map<String, List<SseEmitter>> emitterMap = new ConcurrentHashMap<>();
private final Map<SseEmitter, Runnable> emitterCleanupMap = new ConcurrentHashMap<>();
/**
* Invoked once a given emitter for uniqueKey is cleaned up (completed,
* timed out, or errored). Callers use this to do whatever bookkeeping
* they need (e.g., persisting a Session) using sessionStart/sessionEnd.
*/
@FunctionalInterface
public interface SessionCleanupCallback {
void onCleanup(String uniqueKey, Instant sessionStart, Instant sessionEnd);
}
public SseEmitter subscribe(String uniqueKey, SessionCleanupCallback cleanupCallback) {
return subscribe(uniqueKey, null, cleanupCallback);
}
public SseEmitter subscribe(String uniqueKey, String initialData, SessionCleanupCallback cleanupCallback) {
SseEmitter emitter = new SseEmitter(0L); // no built-in timeout; heartbeat governs liveness
emitterMap.computeIfAbsent(uniqueKey, k -> new CopyOnWriteArrayList<>()).add(emitter);
Instant sessionStart = Instant.now();
log.info("Key {} subscribed, active emitters: {}", uniqueKey, countActive());
AtomicBoolean cleanedUp = new AtomicBoolean();
Runnable cleanup = () -> {
if (cleanedUp.compareAndSet(false, true)) {
handleCleanup(uniqueKey, emitter, sessionStart, cleanupCallback);
}
};
emitterCleanupMap.put(emitter, cleanup);
// covers: clean disconnect (tab closed normally), server-side timeout, write failure
emitter.onCompletion(cleanup);
emitter.onTimeout(cleanup);
emitter.onError(e -> cleanup.run());
try {
if (initialData != null) {
emitter.send(SseEmitter.event().name("connected").data(initialData));
} else {
emitter.send(SseEmitter.event().name("connected"));
}
} catch (IOException e) {
cleanup.run();
}
return emitter;
}
// runs every 20s: keeps idle timeouts from firing,
// AND prunes emitters whose sockets are actually dead
@Scheduled(fixedRate = 20_000)
public void heartbeat() {
emitterMap.values().forEach(list -> {
for (SseEmitter emitter : list) {
try {
emitter.send(SseEmitter.event().comment("heartbeat"));
} catch (IOException e) {
cleanup(emitter);
}
}
});
emitterMap.entrySet().removeIf(e -> e.getValue().isEmpty());
}
public void sendToUser(String uniqueKey, String eventName, String json) {
List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
if (userEmitters == null) {
log.warn("No emitters found for unique key: {}", uniqueKey);
return;
}
for (SseEmitter emitter : userEmitters) {
try {
emitter.send(SseEmitter.event().name(eventName).data(json));
} catch (IOException e) {
log.warn("Failed to send event to user: {}", uniqueKey, e);
cleanup(emitter);
}
}
}
public boolean hasSubscribers(String uniqueKey) {
List<SseEmitter> emitters = emitterMap.get(uniqueKey);
return emitters != null && !emitters.isEmpty();
}
private long countActive() {
return emitterMap.values().stream().mapToLong(List::size).sum();
}
private void cleanup(SseEmitter emitter) {
Runnable cleanup = emitterCleanupMap.get(emitter);
if (cleanup != null) {
cleanup.run();
}
}
private void handleCleanup(String uniqueKey, SseEmitter emitter, Instant sessionStart,
SessionCleanupCallback cleanupCallback) {
emitterCleanupMap.remove(emitter);
List<SseEmitter> userEmitters = emitterMap.get(uniqueKey);
if (userEmitters != null) {
userEmitters.remove(emitter);
if (userEmitters.isEmpty()) {
emitterMap.remove(uniqueKey);
}
}
Instant sessionEnd = Instant.now();
Duration between = Duration.between(sessionStart, sessionEnd);
log.info("Cleaned up emitter for {}, active emitters: {}, session duration: {}",
uniqueKey, countActive(), between
);
if (cleanupCallback != null) {
cleanupCallback.onCleanup(uniqueKey, sessionStart, sessionEnd);
}
}
}
@@ -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, Integer partyId) {
public boolean hasPermission(PermissionClaimDto permission) {
return authorities.contains(permission.getValue());
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) {
return false;
}
EventUser eventUser = (EventUser) o;
return Objects.equals(uuid, eventUser.uuid);
}
@Override
public int hashCode() {
return Objects.hash(uuid);
}
}
@@ -0,0 +1,8 @@
package com.alttd.altitudeweb.services.chat.event_publisher;
@FunctionalInterface
public interface MessageForUser {
String get(EventUser eventUser);
}
@@ -0,0 +1,65 @@
package com.alttd.altitudeweb.services.chat.to_server;
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
@RequiredArgsConstructor
public class ServerMessageService {
private static final String PLAYER_STATE_CHANNEL = "player_state";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final Set<String> servers = ConcurrentHashMap.newKeySet();
private final EventPublisher eventPublisher;
private volatile String latestPlayerState;
public synchronized SseEmitter subscribe(String server) {
servers.add(server);
log.info("Server {} subscribed", server);
SseEmitter emitter = eventPublisher.subscribe(server, this::handleSessionEnd);
if (latestPlayerState != null) {
eventPublisher.sendToUser(server, PLAYER_STATE_CHANNEL, latestPlayerState);
}
return emitter;
}
private synchronized void handleSessionEnd(String key, Instant sessionStart, Instant sessionEnd) {
log.info("Server session ended for {}. Active from {} for {}", key, sessionStart, Duration.between(sessionStart, sessionEnd));
}
public void sendMessageToAll(String channel, Object data) {
String json;
try {
json = OBJECT_MAPPER.writeValueAsString(data);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize server message", e);
}
if (PLAYER_STATE_CHANNEL.equals(channel)) {
latestPlayerState = json;
}
servers.forEach(server -> eventPublisher.sendToUser(server, channel, json));
}
public boolean sendMessage(String server, String channel, String json) {
if (!servers.contains(server)) {
log.warn("Server {} is not connected, not sending message", server);
return false;
}
eventPublisher.sendToUser(server, channel, json);
return true;
}
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.services.chat.to_server.data;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import java.util.UUID;
@Builder
@Getter
public class ChatFromWeb {
private final UUID sender;
private final String message;
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.services.chat.to_server.data;
import lombok.Builder;
import lombok.Getter;
import java.util.UUID;
@Builder
@Getter
public class PartyChatFromWeb {
private final UUID sender;
private final String message;
private final String partyId;
}
@@ -0,0 +1,17 @@
package com.alttd.altitudeweb.services.chat.to_server.data;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.UUID;
@Builder
@Getter
public class PlayerListState {
private List<UUID> activePlayers;
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.services.chat.to_server.data;
import lombok.Builder;
import lombok.Getter;
import java.util.UUID;
@Builder
@Getter
public class PrivateChatFromWeb {
private final UUID sender;
private final String message;
private final UUID recipient;
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.services.chat.to_server.data;
import lombok.Builder;
import lombok.Getter;
import java.util.UUID;
@Builder
@Getter
public class PunishFromWeb {
private final UUID executor;
private final UUID target;
private final String type;
private final String reason;
private final String time;
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.services.chat.to_server.mappers;
import com.alttd.altitudeweb.model.ServerMessageRequestDto;
import com.alttd.altitudeweb.services.chat.to_server.data.ChatFromWeb;
import lombok.experimental.UtilityClass;
@UtilityClass
public class ChatFromWebMapper {
public static ChatFromWeb fromDto(ServerMessageRequestDto dto) {
return ChatFromWeb.builder()
.sender(dto.getUuid())
.message(dto.getMessage())
.build();
}
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.services.chat.to_server.mappers;
import com.alttd.altitudeweb.model.PartyMessageRequestDto;
import com.alttd.altitudeweb.services.chat.to_server.data.PartyChatFromWeb;
import lombok.experimental.UtilityClass;
@UtilityClass
public class PartyChatFromWebMapper {
public static PartyChatFromWeb fromDto(PartyMessageRequestDto dto) {
return PartyChatFromWeb.builder()
.sender(dto.getFrom())
.message(dto.getMessage())
.partyId(dto.getPartyId())
.build();
}
}
@@ -0,0 +1,19 @@
package com.alttd.altitudeweb.services.chat.to_server.mappers;
import com.alttd.altitudeweb.services.chat.to_server.data.PlayerListState;
import lombok.experimental.UtilityClass;
import java.util.ArrayList;
import java.util.UUID;
@UtilityClass
public class PlayerListStateMapper {
public static PlayerListState fromList(ArrayList<UUID> activeViewers) {
return PlayerListState.builder()
.activePlayers(activeViewers)
.build();
}
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.services.chat.to_server.mappers;
import com.alttd.altitudeweb.model.PrivateMessageRequestDto;
import com.alttd.altitudeweb.services.chat.to_server.data.PrivateChatFromWeb;
import lombok.experimental.UtilityClass;
@UtilityClass
public class PrivateChatFromWebMapper {
public static PrivateChatFromWeb fromDto(PrivateMessageRequestDto dto) {
return PrivateChatFromWeb.builder()
.sender(dto.getFrom())
.message(dto.getMessage())
.recipient(dto.getTo())
.build();
}
}
@@ -0,0 +1,20 @@
package com.alttd.altitudeweb.services.chat.to_server.mappers;
import com.alttd.altitudeweb.model.PunishRequestDto;
import com.alttd.altitudeweb.services.chat.to_server.data.PunishFromWeb;
import lombok.experimental.UtilityClass;
@UtilityClass
public class PunishFromWebMapper {
public static PunishFromWeb fromDto(PunishRequestDto dto) {
return PunishFromWeb.builder()
.executor(dto.getExecutor())
.target(dto.getTarget())
.type(dto.getType().getValue())
.reason(dto.getReason())
.time(dto.getTime())
.build();
}
}
@@ -0,0 +1,279 @@
package com.alttd.altitudeweb.services.discord;
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.OutputChannelMapper;
import com.alttd.altitudeweb.database.litebans.HistoryCountMapper;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.litebans.HistoryType;
import com.alttd.altitudeweb.database.litebans.UserType;
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.webinterface.appeals.AppealSender;
import com.alttd.webinterface.objects.MessageForEmbed;
import com.alttd.webinterface.send_message.DiscordSender;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
public class AppealDiscord {
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) {
// Fetch channels
CompletableFuture<List<OutputChannel>> channelsFuture = getChannelListFuture();
CompletableFuture<Integer> bansF = getCountAsync(HistoryType.BAN, appeal.uuid());
CompletableFuture<Integer> mutesF = getCountAsync(HistoryType.MUTE, appeal.uuid());
CompletableFuture<Integer> warnsF = getCountAsync(HistoryType.WARN, appeal.uuid());
CompletableFuture<Integer> kicksF = getCountAsync(HistoryType.KICK, appeal.uuid());
List<OutputChannel> channels = channelsFuture.join();
int bans = bansF.join();
int mutes = mutesF.join();
int warns = warnsF.join();
int kicks = kicksF.join();
if (channels.isEmpty()) {
log.warn("No Discord output channels found for type {}. Skipping Discord send.", OUTPUT_TYPE);
return;
}
// Build embed
boolean active = history.getUntil() == null || history.getUntil() <= 0 || history.getUntil() > System.currentTimeMillis();
String createdAt = formatInstant(appeal.createdAt());
List<DiscordSender.EmbedField> fields = new ArrayList<>();
// Group: User
fields.add(new DiscordSender.EmbedField(
"User",
"Username: `" + safe(appeal.username()) + "`\n" +
"UUID: " + safe(String.valueOf(appeal.uuid())) + "\n" +
"Submitted: " + createdAt,
false
));
// Group: Punishment
fields.add(new DiscordSender.EmbedField(
"Punishment",
"Type: " + safe(String.valueOf(appeal.historyType())) + "\n" +
"ID: " + safe(String.valueOf(appeal.historyId())) + "\n" +
"Reason: " + safe(history.getReason()) + "\n" +
"Active: " + active,
false
));
// Group: Previous punishments
fields.add(new DiscordSender.EmbedField(
"Previous punishments",
"Bans: " + bans + "\n" +
"Mutes: " + mutes + "\n" +
"Warnings: " + warns + "\n" +
"Kicks: " + kicks,
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());
List<Long> channelIds = channels.stream()
.map(OutputChannel::channel)
.toList();
// colorRgb = null (use default), timestamp = appeal.createdAt if available
Instant timestamp = appeal.createdAt() != null ? appeal.createdAt() : Instant.now();
MessageForEmbed newAppealSubmitted = new MessageForEmbed(
"New Appeal Submitted", description, fields, null, timestamp, null);
AppealSender.getInstance().sendAppeal(channelIds, newAppealSubmitted, optionalAssignedTo.orElse(0L));
}
private static CompletableFuture<List<OutputChannel>> getChannelListFuture() {
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
try {
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
.getChannelsWithOutputType(OUTPUT_TYPE);
channelsFuture.complete(channels);
} catch (Exception e) {
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
channelsFuture.complete(new ArrayList<>());
}
});
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) {
CompletableFuture<Integer> future = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS).runQuery(sql -> {
try {
Integer count = sql.getMapper(HistoryCountMapper.class)
.getUuidPunishmentCount(type, UserType.PLAYER, uuid);
future.complete(count == null ? 0 : count);
} catch (Exception e) {
log.error("Failed to load punishment count for {} ({})", type, uuid, e);
future.complete(0);
}
});
return future;
}
private String safe(String s) {
return s == null ? "unknown" : s;
}
private String formatInstant(Instant instant) {
if (instant == null) return "unknown";
return instant.atZone(ZoneId.of("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);
}
}
@@ -0,0 +1,103 @@
package com.alttd.altitudeweb.services.discord;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.discord.OutputChannel;
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import com.alttd.altitudeweb.setup.Connection;
import com.alttd.webinterface.objects.MessageForEmbed;
import com.alttd.webinterface.send_message.DiscordSender;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
public class StaffApplicationDiscord {
private static final String OUTPUT_TYPE = "STAFF_APPLICATION";
public void sendApplicationToDiscord(String username, StaffApplication application) {
// Fetch channels for staff applications
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
try {
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
.getChannelsWithOutputType(OUTPUT_TYPE);
channelsFuture.complete(channels);
} catch (Exception e) {
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
channelsFuture.complete(new ArrayList<>());
}
});
List<OutputChannel> channels = channelsFuture.join();
if (channels.isEmpty()) {
log.warn("No Discord output channels found for type {}. Skipping Discord send.", OUTPUT_TYPE);
return;
}
// Build embed content
List<DiscordSender.EmbedField> fields = new ArrayList<>();
fields.add(new DiscordSender.EmbedField(
"Applicant",
"Username: `" + safe(username) + "`\n" +
"Discord: `" + safe(application.discordUsername()) + "`\n" +
"Email: " + safe(application.email()) + "\n" +
"Age: " + safe(String.valueOf(application.age())) + "\n" +
"Meets reqs: " + (application.meetsRequirements() != null && application.meetsRequirements()),
false
));
fields.add(new DiscordSender.EmbedField(
"Availability",
"Days: " + safe(application.availableDays()) + "\n" +
"Times: " + safe(application.availableTimes()),
false
));
fields.add(new DiscordSender.EmbedField(
"Experience",
"Previous: " + safe(application.previousExperience()) + "\n" +
"Plugins: " + safe(application.pluginExperience()) + "\n" +
"Expectations: " + safe(application.moderatorExpectations()),
false
));
if (application.additionalInfo() != null && !application.additionalInfo().isBlank()) {
fields.add(new DiscordSender.EmbedField(
"Additional Info",
application.additionalInfo(),
false
));
}
List<Long> channelIds = channels.stream()
.map(OutputChannel::channel)
.toList();
Instant timestamp = application.createdAt() != null ? application.createdAt() : Instant.now();
MessageForEmbed messageForEmbed = new MessageForEmbed(
"New Staff Application Submitted",
"Join date: " + (application.joinDate() != null ? application.joinDate().toString() : "unknown") +
"\nSubmitted: " + formatInstant(timestamp),
fields,
null,
timestamp,
null);
DiscordSender.getInstance().sendEmbedWithThreadToChannels(channelIds, messageForEmbed, "Staff Application");
}
private String safe(String s) {
return s == null ? "unknown" : s;
}
private String formatInstant(Instant instant) {
if (instant == null) return "unknown";
return instant.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
}
}
@@ -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,4 +1,4 @@
package com.alttd.altitudeweb.controllers.limits;
package com.alttd.altitudeweb.services.limits;
import org.springframework.stereotype.Service;
@@ -1,4 +1,4 @@
package com.alttd.altitudeweb.controllers.limits;
package com.alttd.altitudeweb.services.limits;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
@@ -1,5 +1,6 @@
package com.alttd.altitudeweb.controllers.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.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@@ -16,6 +17,8 @@ import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method;
import java.time.Duration;
import java.util.Optional;
import java.util.UUID;
@Aspect
@Component
@@ -24,10 +27,11 @@ import java.time.Duration;
public class RateLimitAspect {
private final InMemoryRateLimiterService rateLimiterService;
private final AuthenticatedUuid authenticatedUuid;
@Around("""
@annotation(com.alttd.altitudeweb.controllers.limits.RateLimit)
|| @within(com.alttd.altitudeweb.controllers.limits.RateLimit)""")
@annotation(com.alttd.altitudeweb.services.limits.RateLimit)
|| @within(com.alttd.altitudeweb.services.limits.RateLimit)""")
public Object rateLimit(ProceedingJoinPoint joinPoint) throws Throwable {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) {
@@ -37,7 +41,6 @@ public class RateLimitAspect {
HttpServletRequest request = requestAttributes.getRequest();
HttpServletResponse response = requestAttributes.getResponse();
String clientIp = request.getRemoteAddr();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
@@ -54,7 +57,12 @@ public class RateLimitAspect {
Duration duration = Duration.ofSeconds(rateLimit.timeUnit().toSeconds(rateLimit.timeValue()));
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);
@@ -67,7 +75,7 @@ public class RateLimitAspect {
return joinPoint.proceed();
} 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);
@@ -1,4 +1,4 @@
package com.alttd.altitudeweb.controllers.limits;
package com.alttd.altitudeweb.services.limits;
import java.time.Duration;
import java.time.Instant;
@@ -0,0 +1,111 @@
package com.alttd.altitudeweb.services.mail;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
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.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@Slf4j
@Service
@RequiredArgsConstructor
public class AppealMail {
private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
@Value("${spring.mail.username}")
private String fromEmail;
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.
*
* @param appeal The appeal object containing all necessary information
*/
public void sendAppealNotification(Appeal appeal, HistoryRecord history) {
try {
sendEmailToAppealsTeam(appeal, history);
log.info("Appeal notification emails sent successfully for appeal ID: {}", appeal.id());
} catch (Exception e) {
log.error("Failed to send appeal notification emails for appeal ID: {}", appeal.id(), e);
}
}
private void sendEmailToAppealsTeam(Appeal appeal, HistoryRecord history) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail);
helper.setTo(APPEAL_EMAIL);
helper.setReplyTo(appeal.email());
helper.setSubject("New Appeal Submitted - " + appeal.username());
Context context = new Context();
context.setVariable("appeal", appeal);
context.setVariable("history", history);
context.setVariable("createdAt", appeal.createdAt()
.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'")));
context.setVariable("active", history.getUntil() <= 0 || history.getUntil() > System.currentTimeMillis());
String content = templateEngine.process("appeal-email", context);
helper.setText(content, true);
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,144 @@
package com.alttd.altitudeweb.services.mail;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.setup.Connection;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
@RequiredArgsConstructor
public class MailVerificationService {
private final JavaMailSender mailSender;
@Value("${spring.mail.username}")
private String fromEmail;
public java.util.List<EmailVerification> listAll(UUID userUuid) {
java.util.concurrent.CompletableFuture<java.util.List<EmailVerification>> future = new java.util.concurrent.CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
future.complete(mapper.findAllByUser(userUuid));
});
return future.join();
}
public EmailVerification submitEmail(UUID userUuid, String email) {
String code = generateCode();
Instant now = Instant.now();
final String finalEmail = email.toLowerCase();
CompletableFuture<EmailVerification> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
EmailVerification toPersist;
if (existing == null) {
toPersist = new EmailVerification(UUID.randomUUID(), userUuid, finalEmail, code, false, now, null, now);
mapper.insert(toPersist);
} else {
mapper.updateCodeAndLastSent(existing.id(), code, now);
toPersist = new EmailVerification(existing.id(), userUuid, finalEmail, code, false, existing.createdAt(), null, now);
}
future.complete(toPersist);
});
EmailVerification saved = future.join();
sendVerificationEmail(saved);
return saved;
}
public Optional<EmailVerification> verifyCode(UUID userUuid, String code) {
CompletableFuture<Optional<EmailVerification>> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification found = mapper.findByUserAndCode(userUuid, code);
if (found == null) {
future.complete(Optional.empty());
return;
}
mapper.markVerified(found.id(), Instant.now());
future.complete(Optional.of(found));
});
return future.join();
}
public EmailVerification resend(UUID userUuid, String email) {
String code = generateCode();
Instant now = Instant.now();
final String finalEmail = email.toLowerCase();
CompletableFuture<EmailVerification> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
if (existing != null) {
mapper.updateCodeAndLastSent(existing.id(), code, now);
future.complete(new EmailVerification(existing.id(), userUuid,
finalEmail, code, false, existing.createdAt(), null, now));
} else {
future.complete(null);
}
});
EmailVerification updated = future.join();
if (updated != null) {
sendVerificationEmail(updated);
}
return updated;
}
public boolean delete(UUID userUuid, String email) {
final String finalEmail = email.toLowerCase();
CompletableFuture<Boolean> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
if (existing != null) {
mapper.deleteByUserAndEmail(userUuid, finalEmail);
future.complete(true);
} else {
future.complete(false);
}
});
return future.join();
}
private void sendVerificationEmail(EmailVerification emailVerification) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail);
helper.setTo(emailVerification.email().toLowerCase());
helper.setSubject("Your verification code");
helper.setText("Your verification code is: " + emailVerification.verificationCode(), false);
mailSender.send(message);
} catch (MessagingException e) {
log.error("Failed to send verification email to {}", emailVerification.email(), e);
}
}
private String generateCode() {
// 6-digit numeric code
Random random = new Random();
int num = 100000 + random.nextInt(899999);
return String.valueOf(num);
}
}
@@ -0,0 +1,72 @@
package com.alttd.altitudeweb.services.mail;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@Slf4j
@Service
@RequiredArgsConstructor
public class StaffApplicationMail {
private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
@Value("${spring.mail.username}")
private String fromEmail;
private static final String STAFF_APPLICATION_EMAIL = "[email protected]";
/**
* Sends an email with the staff application details to the staff applications team mailbox.
* Returns true if the email was sent successfully.
*/
public boolean sendApplicationEmail(String username, StaffApplication application) {
try {
doSend(username, application);
log.info("Staff application email sent successfully for application ID: {}", application.id());
return true;
} catch (Exception e) {
log.error("Failed to send staff application email for application ID: {}", application.id(), e);
return false;
}
}
private void doSend(String username, StaffApplication application) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail);
helper.setTo(STAFF_APPLICATION_EMAIL);
helper.setReplyTo(application.email());
helper.setSubject("Staff Application: " + safe(application.discordUsername()));
// Prepare template context
String createdAt = application.createdAt()
.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
Context context = new Context();
context.setVariable("application", application);
context.setVariable("createdAt", createdAt);
context.setVariable("username", username);
String content = templateEngine.process("staff-application-email", context);
helper.setText(content, true);
mailSender.send(message);
}
private String safe(String s) {
return s == null ? "unknown" : s;
}
}
@@ -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));
}
}
@@ -0,0 +1,47 @@
package com.alttd.altitudeweb.services.site;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper;
import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow;
import com.alttd.altitudeweb.mappers.VotingStatsRowToVoteDataDto;
import com.alttd.altitudeweb.model.VoteDataDto;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
@RequiredArgsConstructor
public class VoteService {
private final VotingStatsRowToVoteDataDto votingStatsRowToVoteDataDto;
public Optional<VoteDataDto> getVoteStats(UUID uuid) {
CompletableFuture<Optional<VoteDataDto>> voteDataDtoFuture = new CompletableFuture<>();
Connection.getConnection(Databases.VOTING_PLUGIN).runQuery(sqlSession -> {
try {
VotingPluginUsersMapper votingPluginUsersMapper = sqlSession.getMapper(VotingPluginUsersMapper.class);
Optional<VotingStatsRow> optionalVotingStatsRow = votingPluginUsersMapper.getStatsByUuid(uuid);
if (optionalVotingStatsRow.isEmpty()) {
log.debug("No voting stats found for {}", uuid);
voteDataDtoFuture.complete(Optional.empty());
return;
}
VotingStatsRow votingStatsRow = optionalVotingStatsRow.get();
VoteDataDto voteDataDto = votingStatsRowToVoteDataDto.map(votingStatsRow);
voteDataDtoFuture.complete(Optional.of(voteDataDto));
} catch (Exception e) {
log.error("Failed to get vote data for {}", uuid, e);
voteDataDtoFuture.completeExceptionally(e);
}
});
return voteDataDtoFuture.join();//TODO handle exception
}
}
@@ -0,0 +1,27 @@
package com.alttd.altitudeweb.services.user;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String uuid) throws UsernameNotFoundException {
try {
//Validate uuid
UUID.fromString(uuid);
return new User(uuid, "", Collections.emptyList());
} catch (IllegalArgumentException e) {
throw new UsernameNotFoundException("Invalid UUID format: " + uuid);
}
}
}
@@ -0,0 +1,9 @@
spring.application.name=AltitudeWeb
database.name=${DB_NAME:web_db}
database.port=${DB_PORT:3306}
database.host=${DB_HOST:localhost}
database.user=${DB_USER:root}
database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:https://beta.alttd.com}
my-server.address=${SERVER_ADDRESS:https://beta.alttd.com}
logging.level.com.alttd.altitudeweb=DEBUG
@@ -1,8 +1,4 @@
spring.application.name=AltitudeWeb
database.name=${DB_NAME:web_db}
database.port=${DB_PORT:3306}
database.host=${DB_HOST:localhost}
database.user=${DB_USER:root}
database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:http://localhost:4200}
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080,http://localhost:80}
my-server.address=${SERVER_ADDRESS:http://localhost:8080}
logging.level.com.alttd.altitudeweb=DEBUG
logging.level.org.springframework.security=DEBUG
@@ -5,4 +5,23 @@ database.host=${DB_HOST:localhost}
database.user=${DB_USER:root}
database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:https://alttd.com}
login.secret=${LOGIN_SECRET:SET_TOKEN}
particles.file_path=${PARTICLE_FILE_PATH:${user.home}/.altitudeweb/particles}
notification.server.url=${SERVER_IP:10.0.0.107}:${SERVER_PORT:8080}
my-server.address=${SERVER_ADDRESS:https://alttd.com}
logging.level.com.alttd.altitudeweb=INFO
discord.token=${DISCORD_TOKEN}
spring.mail.host=${MAIL_HOST:smtp.zoho.com}
spring.mail.port=${MAIL_PORT:465}
spring.mail.username=${MAIL_USER}
spring.mail.password=${MAIL_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.ssl.enable=true
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
chat.allowed-ip=${ALLOWED_IP:10.0.0.0/24}
chat.allowed-servers=${ALLOWED_SERVERS:bayou,lobby,creative}
management.endpoints.web.exposure.include=mappings
server.forward-headers-strategy=native
server.tomcat.remoteip.remote-ip-header=CF-Connecting-IP
server.tomcat.remoteip.trusted-proxies=10\\.0\\.0\\.103
altitudeweb.token=${TOKEN:invalid-token}
@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
<meta charset="UTF-8">
<title>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.username}">Appeal by Username</h1>
<section class="columnSection">
<div class="columnContainer">
<div>
<h2>User information</h2>
<ul>
<li><strong>Username:</strong> <span th:text="${appeal.username}">username</span></li>
<li><strong>UUID:</strong> <span th:text="${appeal.uuid}">uuid</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>
<li><strong>Reason:</strong> <span th:text="${history.reason}">reason</span></li>
<li><strong>Active:</strong> <span th:text="${active}">unknown</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>
@@ -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>
@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
<meta charset="UTF-8">
<title>Staff Application</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="'Staff application by ' + ${application.discordUsername}">Staff application</h1>
<section class="columnSection">
<div class="columnContainer">
<div>
<h2>Applicant</h2>
<ul>
<li><strong>Username:</strong> <span th:text="${username}">uuid</span></li>
<li><strong>Email:</strong> <span th:text="${application.email}">email</span></li>
<li><strong>Discord:</strong> <span th:text="${application.discordUsername}">discord</span></li>
<li><strong>Age:</strong> <span th:text="${application.age}">age</span></li>
<li><strong>Pronouns:</strong> <span th:text="${application.pronouns}">pronouns</span></li>
<li><strong>Join date:</strong> <span th:text="${application.joinDate}">date</span></li>
<li><strong>Submitted at:</strong> <span th:text="${createdAt}">date</span></li>
</ul>
</div>
</div>
<div class="columnContainer">
<div>
<h2>Availability</h2>
<ul>
<li><strong>Days:</strong> <span th:text="${application.availableDays}">days</span></li>
<li><strong>Times:</strong> <span th:text="${application.availableTimes}">times</span></li>
<li><strong>Weekly playtime:</strong> <span th:text="${application.weeklyPlaytime}">0</span> hours</li>
</ul>
<h2>Experience</h2>
<p><strong>Previous:</strong><br><span th:text="${application.previousExperience}">previous experience</span></p>
<p><strong>Plugins:</strong><br><span th:text="${application.pluginExperience}">plugin experience</span></p>
<p><strong>Expectations:</strong><br><span th:text="${application.moderatorExpectations}">moderator expectations</span></p>
<div th:if="${application.additionalInfo} != null and ${application.additionalInfo} != ''">
<h2>Additional info</h2>
<p th:text="${application.additionalInfo}">additional info</p>
</div>
</div>
</div>
</section>
</main>
</body>
</html>
@@ -0,0 +1,75 @@
package com.alttd.altitudeweb.controllers.chat;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.model.ServerStateDto;
import com.alttd.altitudeweb.model.PlayerListVisibilityDto;
import com.alttd.altitudeweb.services.chat.ChatService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class ChatControllerTest {
@Mock
private ChatService chatService;
@Mock
private AuthenticatedUuid authenticatedUuid;
@InjectMocks
private ChatController chatController;
@Test
void testUpdateServerStatesDelegatesToService() {
ServerStateDto serverStateDto = new ServerStateDto();
ResponseEntity<Void> response = chatController.updateServerStates(serverStateDto);
verify(chatService).updateServerState(serverStateDto);
assertEquals(HttpStatus.ACCEPTED, response.getStatusCode());
}
@Test
void hideUserFromPlayerListDelegatesForAuthenticatedUser() {
UUID uuid = UUID.randomUUID();
org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid);
ResponseEntity<Void> response = chatController.hideUserFromPlayerList();
verify(chatService).hideUserFromPlayerList(uuid);
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
}
@Test
void showUserInPlayerListDelegatesForAuthenticatedUser() {
UUID uuid = UUID.randomUUID();
org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid);
ResponseEntity<Void> response = chatController.showUserInPlayerList();
verify(chatService).showUserInPlayerList(uuid);
assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode());
}
@Test
void getPlayerListVisibilityReturnsBackendState() {
UUID uuid = UUID.randomUUID();
org.mockito.Mockito.when(authenticatedUuid.getAuthenticatedUserUuid()).thenReturn(uuid);
org.mockito.Mockito.when(chatService.isUserHiddenFromPlayerList(uuid)).thenReturn(true);
ResponseEntity<PlayerListVisibilityDto> response = chatController.getPlayerListVisibility();
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(true, response.getBody().getHidden());
}
}
@@ -0,0 +1,239 @@
package com.alttd.altitudeweb.services.chat;
import com.alttd.altitudeweb.controllers.chat.ChatMessage;
import com.alttd.altitudeweb.controllers.chat.ChatMessageType;
import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.model.ServerDto;
import com.alttd.altitudeweb.model.ServerStateDto;
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
import com.alttd.altitudeweb.services.chat.event_publisher.EventUser;
import com.alttd.altitudeweb.services.chat.event_publisher.MessageForUser;
import com.alttd.altitudeweb.services.chat.to_server.ServerMessageService;
import com.alttd.altitudeweb.services.chat.to_server.data.PlayerListState;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
class ChatServiceTest {
private ChatService chatService;
private EventPublisher eventPublisher;
private ServerMessageService serverMessageService;
@BeforeEach
void setUp() {
eventPublisher = mock(EventPublisher.class);
serverMessageService = mock(ServerMessageService.class);
chatService = spy(new ChatService(eventPublisher, serverMessageService));
ReflectionTestUtils.setField(chatService, "allowedServers", new String[]{"server1"});
}
@Test
void testPublicMessageSentToEveryone() {
ChatMessage message = ChatMessage.builder()
.uuid(UUID.randomUUID())
.type(ChatMessageType.PUBLIC)
.server("server1")
.messageJson("hello")
.notBlocked(true)
.timestamp(Instant.now())
.build();
chatService.addChatMessage(List.of(message));
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
verify(chatService).sendToUsers(anyString(), captor.capture());
MessageForUser messageForUser = captor.getValue();
EventUser user = new EventUser(UUID.randomUUID(), List.of(), null);
String result = messageForUser.get(user);
assertTrue(result.contains("hello"));
}
@Test
void testGacMessageSentOnlyToStaff() {
ChatMessage message = ChatMessage.builder()
.uuid(UUID.randomUUID())
.type(ChatMessageType.GAC)
.server("server1")
.messageJson("staff chat")
.notBlocked(true)
.timestamp(Instant.now())
.build();
chatService.addChatMessage(List.of(message));
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
verify(chatService).sendToUsers(anyString(), captor.capture());
MessageForUser messageForUser = captor.getValue();
EventUser regularUser = new EventUser(UUID.randomUUID(), List.of(), null);
assertFalse(messageForUser.get(regularUser).contains("staff chat"), "Regular user should not see GAC message");
EventUser modUser = new EventUser(UUID.randomUUID(), List.of(PermissionClaimDto.MOD.getValue()), null);
assertTrue(messageForUser.get(modUser).contains("staff chat"), "MOD should see GAC message");
}
@Test
void testMsgMessageSentToSenderAndReceiver() {
UUID senderUuid = UUID.randomUUID();
UUID receiverUuid = UUID.randomUUID();
ChatMessage message = ChatMessage.builder()
.uuid(senderUuid)
.type(ChatMessageType.MSG)
.server("server1")
.receiver(receiverUuid.toString())
.messageJson("private message")
.notBlocked(true)
.timestamp(Instant.now())
.build();
chatService.addChatMessage(List.of(message));
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
verify(chatService).sendToUsers(anyString(), captor.capture());
MessageForUser messageForUser = captor.getValue();
EventUser sender = new EventUser(senderUuid, List.of(), null);
EventUser receiver = new EventUser(receiverUuid, List.of(), null);
EventUser other = new EventUser(UUID.randomUUID(), List.of(), null);
assertTrue(messageForUser.get(receiver).contains("private message"), "Receiver should see the message");
assertFalse(messageForUser.get(other).contains("private message"), "Others should not see private message");
}
@Test
void testGlobalMessageSentToEveryone() {
ChatMessage message = ChatMessage.builder()
.uuid(UUID.randomUUID())
.type(ChatMessageType.GLOBAL)
.server("server1")
.messageJson("global hello")
.notBlocked(true)
.timestamp(Instant.now())
.build();
chatService.addChatMessage(List.of(message));
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
verify(chatService).sendToUsers(anyString(), captor.capture());
MessageForUser messageForUser = captor.getValue();
EventUser user = new EventUser(UUID.randomUUID(), List.of(), null);
assertTrue(messageForUser.get(user).contains("global hello"));
}
@Test
void testBlockedMessageOnlySentToHeadMod() {
ChatMessage message = ChatMessage.builder()
.uuid(UUID.randomUUID())
.type(ChatMessageType.PUBLIC)
.server("server1")
.messageJson("blocked message")
.notBlocked(false)
.timestamp(Instant.now())
.build();
chatService.addChatMessage(List.of(message));
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
verify(chatService).sendToUsers(anyString(), captor.capture());
MessageForUser messageForUser = captor.getValue();
EventUser regularUser = new EventUser(UUID.randomUUID(), List.of(), null);
EventUser headModUser = new EventUser(UUID.randomUUID(), List.of(PermissionClaimDto.HEAD_MOD.getValue()), null);
assertFalse(messageForUser.get(regularUser).contains("blocked message"));
assertTrue(messageForUser.get(headModUser).contains("blocked message"));
}
@Test
void testUpdateServerStateFiltering() {
ServerStateDto serverStateDto = new ServerStateDto()
.addServersItem(new ServerDto().name("server1"))
.addServersItem(new ServerDto().name("server2"));
chatService.updateServerState(serverStateDto);
ArgumentCaptor<MessageForUser> captor = ArgumentCaptor.forClass(MessageForUser.class);
verify(chatService).sendToUsers(eq("server-state"), captor.capture());
MessageForUser messageForUser = captor.getValue();
EventUser regularUser = new EventUser(UUID.randomUUID(), List.of(), null);
String regularResult = messageForUser.get(regularUser);
assertTrue(regularResult.contains("server1"), "Regular user should see allowed server");
assertFalse(regularResult.contains("server2"), "Regular user should NOT see non-allowed server");
EventUser headModUser = new EventUser(UUID.randomUUID(), List.of(PermissionClaimDto.HEAD_MOD.getValue()), null);
String headModResult = messageForUser.get(headModUser);
assertTrue(headModResult.contains("server1"), "HEAD_MOD should see allowed server");
assertTrue(headModResult.contains("server2"), "HEAD_MOD should see non-allowed server");
}
@Test
void subscribingUserBroadcastsActivePlayerStateOnce() {
UUID uuid = UUID.randomUUID();
EventUser eventUser = new EventUser(uuid, List.of(), null);
chatService.subscribe(eventUser, "[]");
chatService.subscribe(eventUser, "[]");
ArgumentCaptor<PlayerListState> stateCaptor = ArgumentCaptor.forClass(PlayerListState.class);
verify(serverMessageService, times(1)).sendMessageToAll(eq("player_state"), stateCaptor.capture());
assertEquals(List.of(uuid), stateCaptor.getValue().getActivePlayers());
}
@Test
void headModCanHideCurrentSessionFromPlayerState() {
UUID uuid = UUID.randomUUID();
EventUser eventUser = new EventUser(uuid, List.of(PermissionClaimDto.HEAD_MOD.getValue()), null);
chatService.subscribe(eventUser, "[]");
clearInvocations(serverMessageService);
chatService.hideUserFromPlayerList(uuid);
ArgumentCaptor<PlayerListState> stateCaptor = ArgumentCaptor.forClass(PlayerListState.class);
verify(serverMessageService).sendMessageToAll(eq("player_state"), stateCaptor.capture());
assertTrue(stateCaptor.getValue().getActivePlayers().isEmpty());
assertTrue(chatService.isUserHiddenFromPlayerList(uuid));
clearInvocations(serverMessageService);
chatService.showUserInPlayerList(uuid);
verify(serverMessageService).sendMessageToAll(eq("player_state"), stateCaptor.capture());
assertEquals(List.of(uuid), stateCaptor.getValue().getActivePlayers());
assertFalse(chatService.isUserHiddenFromPlayerList(uuid));
}
@Test
void modCannotHideCurrentSessionFromPlayerState() {
UUID uuid = UUID.randomUUID();
EventUser eventUser = new EventUser(uuid, List.of(PermissionClaimDto.MOD.getValue()), null);
chatService.subscribe(eventUser, "[]");
clearInvocations(serverMessageService);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> chatService.hideUserFromPlayerList(uuid));
assertEquals(403, exception.getStatusCode().value());
verifyNoInteractions(serverMessageService);
}
}
@@ -0,0 +1,52 @@
package com.alttd.altitudeweb.services.chat.to_server;
import com.alttd.altitudeweb.services.chat.event_publisher.EventPublisher;
import com.alttd.altitudeweb.services.chat.to_server.data.PlayerListState;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ServerMessageServiceTest {
@Test
void broadcastsSerializedPlayerStateToEveryServer() {
EventPublisher eventPublisher = mock(EventPublisher.class);
when(eventPublisher.subscribe(any(), any(EventPublisher.SessionCleanupCallback.class)))
.thenReturn(new SseEmitter());
ServerMessageService service = new ServerMessageService(eventPublisher);
UUID uuid = UUID.randomUUID();
service.subscribe("server-one");
service.subscribe("server-two");
service.sendMessageToAll("player_state", PlayerListState.builder()
.activePlayers(List.of(uuid))
.build());
String expectedJson = "{\"activePlayers\":[\"" + uuid + "\"]}";
verify(eventPublisher).sendToUser("server-one", "player_state", expectedJson);
verify(eventPublisher).sendToUser("server-two", "player_state", expectedJson);
}
@Test
void newlySubscribedServerReceivesLatestPlayerState() {
EventPublisher eventPublisher = mock(EventPublisher.class);
when(eventPublisher.subscribe(eq("server-one"), any(EventPublisher.SessionCleanupCallback.class)))
.thenReturn(new SseEmitter());
ServerMessageService service = new ServerMessageService(eventPublisher);
service.sendMessageToAll("player_state", PlayerListState.builder()
.activePlayers(List.of())
.build());
service.subscribe("server-one");
verify(eventPublisher).sendToUser("server-one", "player_state", "{\"activePlayers\":[]}");
}
}
+1
View File
@@ -16,6 +16,7 @@ dependencies {
implementation("org.mybatis:mybatis:3.5.13")
compileOnly("org.slf4j:slf4j-api:2.0.17")
compileOnly("org.slf4j:slf4j-simple:2.0.17")
compileOnly("org.jetbrains:annotations:26.0.2")
}
tasks.test {
@@ -6,7 +6,11 @@ import lombok.Getter;
public enum Databases {
DEFAULT("web_db"),
LUCK_PERMS("luckperms"),
LITE_BANS("litebans");
LITE_BANS("litebans"),
DISCORD("discordLink"),
PROXY_PLAYTIME("proxyplaytime"),
VOTING_PLUGIN("votingplugin"),
CHAT("chat");
private final String internalName;
@@ -0,0 +1,21 @@
package com.alttd.altitudeweb.database.chat;
import lombok.Builder;
import lombok.Getter;
import java.sql.Timestamp;
@Builder
@Getter
public class ChatLogDao {
private final String uuid;
private final Timestamp timeStamp;
private final String server;
private final String type;
private final String channel;
private final String receiver;
private final String miniMessage;
private final boolean blocked;
}
@@ -0,0 +1,25 @@
package com.alttd.altitudeweb.database.chat;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Optional;
public interface ChatLogMapper {
@Select("""
SELECT uuid, time_stamp, server, type, channel, receiver, mini_message, blocked
FROM chat_log
WHERE mini_message IS NOT NULL
AND time_stamp >= #{since}
""")
List<ChatLogDao> getChatLogs(@Param("since") long since);
@Select("""
SELECT party_id FROM chat_users
WHERE uuid = #{uuid}
""")
Optional<Integer> getPartyId(@Param("uuid") String uuid);
}
@@ -0,0 +1,15 @@
package com.alttd.altitudeweb.database.chat;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
public interface NicknameMapper {
@Select("""
SELECT nickname
FROM nicknames
WHERE uuid = #{uuid}
""")
Optional<String> getNicknameFromUUID(@Param("uuid") String uuid);
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.database.chat;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
public interface PartyMapper {
@Select("""
SELECT party_name FROM parties
WHERE id = #{id}
""")
Optional<String> getPartyName(@Param("id") int id);
}
@@ -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);
}
@@ -0,0 +1,4 @@
package com.alttd.altitudeweb.database.discord;
public record OutputChannel(long guild, String outputType, long channel, String channelType) {
}
@@ -0,0 +1,15 @@
package com.alttd.altitudeweb.database.discord;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface OutputChannelMapper {
@Select("""
SELECT guild, output_type, channel, channel_type
FROM output_channels
WHERE output_type = #{outputType}
""")
List<OutputChannel> getChannelsWithOutputType(@Param("outputType") String outputType);
}
@@ -0,0 +1,64 @@
package com.alttd.altitudeweb.database.litebans;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import org.jetbrains.annotations.NotNull;
public interface EditHistoryMapper {
@Update("""
UPDATE ${table_name}
SET reason = #{reason}
WHERE id = #{id}
""")
int updateReason(@Param("table_name") String tableName,
@Param("id") int id,
@Param("reason") String reason);
@Update("""
UPDATE ${table_name}
SET until = #{until}
WHERE id = #{id}
""")
int updateUntil(@Param("table_name") String tableName,
@Param("id") int id,
@Param("until") Long until);
@Delete("""
DELETE FROM ${table_name}
WHERE id = #{id}
""")
int deletePunishment(@Param("table_name") String tableName,
@Param("id") int id);
default int setReason(@NotNull HistoryType type, int id, String reason) {
return switch (type) {
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
case BAN -> updateReason("litebans_bans", id, reason);
case MUTE -> updateReason("litebans_mutes", id, reason);
case KICK -> updateReason("litebans_kicks", id, reason);
case WARN -> updateReason("litebans_warnings", id, reason);
};
}
default int setUntil(@NotNull HistoryType type, int id, Long until) {
return switch (type) {
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
case BAN -> updateUntil("litebans_bans", id, until);
case MUTE -> updateUntil("litebans_mutes", id, until);
case KICK -> throw new IllegalArgumentException("KICK has no until");
case WARN -> updateUntil("litebans_warnings", id, until);
};
}
default int remove(@NotNull HistoryType type, int id) {
return switch (type) {
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
case BAN -> deletePunishment("litebans_bans", id);
case MUTE -> deletePunishment("litebans_mutes", id);
case KICK -> deletePunishment("litebans_kicks", id);
case WARN -> deletePunishment("litebans_warnings", id);
};
}
}
@@ -5,6 +5,7 @@ import lombok.Data;
@Data
public class HistoryRecord {
private String uuid;
private int id;
private String punishedName;
private String reason;
private String bannedByUuid;
@@ -0,0 +1,54 @@
package com.alttd.altitudeweb.database.litebans;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Result;
import org.apache.ibatis.annotations.Results;
import org.apache.ibatis.annotations.Select;
public interface IdHistoryMapper {
@Results({
@Result(property = "id", column = "id"),
@Result(property = "uuid", column = "uuid"),
@Result(property = "punishedName", column = "punished_name"),
@Result(property = "reason", column = "reason"),
@Result(property = "bannedByUuid", column = "banned_by_uuid"),
@Result(property = "bannedByName", column = "banned_by_name"),
@Result(property = "removedByName", column = "removed_by_name"),
@Result(property = "time", column = "time"),
@Result(property = "until", column = "until"),
@Result(property = "removedByReason", column = "removed_by_reason")
})
@Select("""
SELECT punishment.id,
punishment.uuid,
user_lookup.name AS punished_name,
punishment.reason,
punishment.banned_by_uuid,
punishment.banned_by_name,
punishment.removed_by_name,
punishment.time,
punishment.until,
punishment.removed_by_reason
FROM (SELECT *
FROM ${table_name} AS punishment
WHERE punishment.id = #{id})
AS punishment
INNER JOIN user_lookup
ON user_lookup.uuid = punishment.uuid
""")
HistoryRecord getHistoryForId(@Param("table_name") String tableName,
@Param("type") String type,
@Param("id") int id);
default HistoryRecord getRecentHistory(HistoryType historyType, int id) {
HistoryRecord historyRecord = switch (historyType) {
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
case BAN -> getHistoryForId("litebans_bans", "ban", id);
case MUTE -> getHistoryForId("litebans_mutes", "mute", id);
case KICK -> getHistoryForId("litebans_kicks", "kick", id);
case WARN -> getHistoryForId("litebans_warnings", "warn", id);
};
historyRecord.setType(historyType.name().toLowerCase());
return historyRecord;
}
}
@@ -4,6 +4,7 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Result;
import org.apache.ibatis.annotations.Results;
import org.apache.ibatis.annotations.Select;
import org.jetbrains.annotations.NotNull;
import java.util.List;
@@ -24,6 +25,7 @@ public interface NameHistoryMapper {
* order
*/
@Results({
@Result(property = "id", column = "id"),
@Result(property = "uuid", column = "uuid"),
@Result(property = "punishedName", column = "punished_name"),
@Result(property = "reason", column = "reason"),
@@ -36,7 +38,8 @@ public interface NameHistoryMapper {
@Result(property = "type", column = "type")
})
@Select("""
SELECT all_punishments.uuid,
SELECT id,
all_punishments.uuid,
user_lookup.name AS punished_name,
reason,
banned_by_uuid,
@@ -62,7 +65,6 @@ public interface NameHistoryMapper {
* Retrieves a list of all types of recent punishment history records sorted
* in descending time order. This result does NOT contain kicks history
*
* @param nameColumn the column name in the database indicating the name to use for filtering
* @param limit the maximum number of records to fetch
* @param offset the starting offset position of the result set
*
@@ -70,6 +72,7 @@ public interface NameHistoryMapper {
* order
*/
@Results({
@Result(property = "id", column = "id"),
@Result(property = "uuid", column = "uuid"),
@Result(property = "punishedName", column = "punished_name"),
@Result(property = "reason", column = "reason"),
@@ -82,17 +85,19 @@ public interface NameHistoryMapper {
@Result(property = "type", column = "type")
})
@Select("""
SELECT punishments.uuid,
SELECT punishment.id,
punishment.uuid,
user_lookup.name AS punished_name,
punishments.reason,
punishments.banned_by_uuid,
punishments.banned_by_name,
punishments.removed_by_name,
punishments.time,
punishments.until,
punishments.removed_by_reason,
punishments.type
punishment.reason,
punishment.banned_by_uuid,
punishment.banned_by_name,
punishment.removed_by_name,
punishment.time,
punishment.until,
punishment.removed_by_reason,
punishment.type
FROM (SELECT uuid,
id,
reason,
banned_by_uuid,
banned_by_name,
@@ -103,9 +108,9 @@ public interface NameHistoryMapper {
type
FROM all_punishments
ORDER BY time DESC
LIMIT #{limit} OFFSET #{offset}) AS punishments
LIMIT #{limit} OFFSET #{offset}) AS punishment
INNER JOIN user_lookup
ON user_lookup.uuid = punishments.uuid;
ON user_lookup.uuid = punishment.uuid;
""")
List<HistoryRecord> getRecentAllHistory(@Param("limit") int limit,
@Param("offset") int offset);
@@ -124,6 +129,7 @@ public interface NameHistoryMapper {
* order
*/
@Results({
@Result(property = "id", column = "id"),
@Result(property = "uuid", column = "uuid"),
@Result(property = "punishedName", column = "punished_name"),
@Result(property = "reason", column = "reason"),
@@ -135,22 +141,24 @@ public interface NameHistoryMapper {
@Result(property = "removedByReason", column = "removed_by_reason")
})
@Select("""
SELECT punishment.uuid,
SELECT punishment.id,
punishment.uuid,
user_lookup.name AS punished_name,
reason,
banned_by_uuid,
banned_by_name,
removed_by_name,
time,
until,
removed_by_reason
FROM ${tableName} AS punishment
INNER JOIN user_lookup ON user_lookup.uuid = punishment.uuid
punishment.reason,
punishment.banned_by_uuid,
punishment.banned_by_name,
punishment.removed_by_name,
punishment.time,
punishment.until,
punishment.removed_by_reason
FROM ${table_name} AS punishment
INNER JOIN user_lookup
ON user_lookup.uuid = punishment.uuid
WHERE ${name_column} LIKE #{partialName}
ORDER BY time DESC
LIMIT #{limit} OFFSET #{offset}
""")
List<HistoryRecord> getRecentHistoryForName(@Param("tableName") String tableName,
List<HistoryRecord> getRecentHistoryForName(@Param("table_name") String tableName,
@Param("partialName") String partialName,
@Param("name_column") String nameColumn,
@Param("limit") int limit,
@@ -161,8 +169,6 @@ public interface NameHistoryMapper {
* parameters. The records are sorted in descending order by time and limited to a specified range.
*
* @param tableName the name of the database table to query the history records from
* @param partialName a partial or complete name of the user to filter the records, case-insensitive
* @param nameColumn the column name in the database indicating the name to use for filtering
* @param limit the maximum number of records to fetch
* @param offset the starting offset position of the result set
*
@@ -170,6 +176,7 @@ public interface NameHistoryMapper {
* order
*/
@Results({
@Result(property = "id", column = "id"),
@Result(property = "uuid", column = "uuid"),
@Result(property = "punishedName", column = "punished_name"),
@Result(property = "reason", column = "reason"),
@@ -181,7 +188,8 @@ public interface NameHistoryMapper {
@Result(property = "removedByReason", column = "removed_by_reason")
})
@Select("""
SELECT punishment.uuid,
SELECT punishment.id,
punishment.uuid,
user_lookup.name AS punished_name,
punishment.reason,
punishment.banned_by_uuid,
@@ -191,7 +199,8 @@ public interface NameHistoryMapper {
punishment.until,
punishment.removed_by_reason
FROM (
SELECT uuid,
SELECT id,
uuid,
reason,
banned_by_uuid,
banned_by_name,
@@ -199,14 +208,13 @@ public interface NameHistoryMapper {
time,
until,
removed_by_reason
FROM ${tableName} AS punishment
WHERE ${name_column} LIKE #{partialName}
FROM ${table_name} AS punishment
ORDER BY time DESC
LIMIT #{limit} OFFSET #{offset}
) AS punishment
INNER JOIN user_lookup ON user_lookup.uuid = punishment.uuid
""")
List<HistoryRecord> getRecentHistory(@Param("tableName") String tableName,
List<HistoryRecord> getRecentHistory(@Param("table_name") String tableName,
@Param("limit") int limit,
@Param("offset") int offset);
@@ -254,23 +262,23 @@ public interface NameHistoryMapper {
}
}
private List<HistoryRecord> getRecentBans(UserType userType, String partialName, int page) {
private @NotNull List<HistoryRecord> getRecentBans(UserType userType, String partialName, int page) {
return addType(getRecent("litebans_bans", userType, partialName, page), "ban");
}
private List<HistoryRecord> getRecentKicks(UserType userType, String partialName, int page) {
private @NotNull List<HistoryRecord> getRecentKicks(UserType userType, String partialName, int page) {
return addType(getRecent("litebans_kicks", userType, partialName, page), "kick");
}
private List<HistoryRecord> getRecentMutes(UserType userType, String partialName, int page) {
private @NotNull List<HistoryRecord> getRecentMutes(UserType userType, String partialName, int page) {
return addType(getRecent("litebans_mutes", userType, partialName, page), "mute");
}
private List<HistoryRecord> getRecentWarns(UserType userType, String partialName, int page) {
private @NotNull List<HistoryRecord> getRecentWarns(UserType userType, String partialName, int page) {
return addType(getRecent("litebans_warnings", userType, partialName, page), "warn");
}
private List<HistoryRecord> addType(List<HistoryRecord> historyRecords, String type) {
private @NotNull List<HistoryRecord> addType(@NotNull List<HistoryRecord> historyRecords, String type) {
historyRecords.forEach(historyRecord -> historyRecord.setType(type));
return historyRecords;
}
@@ -7,6 +7,17 @@ import java.util.ArrayList;
import java.util.List;
public interface RecentNamesMapper {
@Select("""
SELECT DISTINCT name AS username
FROM litebans_history
WHERE uuid = #{uuid}
ORDER BY date DESC
LIMIT 1;
""")
String getUsername(@Param("uuid") String uuid);
@Select("""
SELECT DISTINCT user_lookup.name AS punished_name
FROM ${tableName} AS punishment
@@ -1,10 +1,11 @@
package com.alttd.altitudeweb.database.litebans;
import com.alttd.altitudeweb.type_handler.UUIDTypeHandler;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Result;
import org.apache.ibatis.annotations.Results;
import org.apache.ibatis.annotations.Select;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.UUID;
@@ -14,10 +15,11 @@ public interface UUIDHistoryMapper {
int PAGE_SIZE = 10;
@Results({
@Result(property = "uuid", column = "uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class),
@Result(property = "id", column = "id"),
@Result(property = "uuid", column = "uuid"),
@Result(property = "punishedName", column = "punished_name"),
@Result(property = "reason", column = "reason"),
@Result(property = "bannedByUuid", column = "banned_by_uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class),
@Result(property = "bannedByUuid", column = "banned_by_uuid"),
@Result(property = "bannedByName", column = "banned_by_name"),
@Result(property = "removedByName", column = "removed_by_name"),
@Result(property = "time", column = "time"),
@@ -26,7 +28,7 @@ public interface UUIDHistoryMapper {
@Result(property = "type", column = "type")
})
@Select("""
SELECT all_punishments.uuid, user_lookup.name AS punished_name, reason, banned_by_uuid, banned_by_name,
SELECT id, all_punishments.uuid, user_lookup.name AS punished_name, reason, banned_by_uuid, banned_by_name,
removed_by_name, time, until, removed_by_reason, type
FROM all_punishments
INNER JOIN user_lookup
@@ -41,10 +43,11 @@ public interface UUIDHistoryMapper {
@Param("offset") int offset);
@Results({
@Result(property = "uuid", column = "uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class),
@Result(property = "id", column = "id"),
@Result(property = "uuid", column = "uuid"),
@Result(property = "punishedName", column = "punished_name"),
@Result(property = "reason", column = "reason"),
@Result(property = "bannedByUuid", column = "banned_by_uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class),
@Result(property = "bannedByUuid", column = "banned_by_uuid"),
@Result(property = "bannedByName", column = "banned_by_name"),
@Result(property = "removedByName", column = "removed_by_name"),
@Result(property = "time", column = "time"),
@@ -52,7 +55,7 @@ public interface UUIDHistoryMapper {
@Result(property = "removedByReason", column = "removed_by_reason")
})
@Select("""
SELECT punishment.uuid, user_lookup.name AS punished_name, reason, banned_by_uuid, banned_by_name,
SELECT punishment.id, punishment.uuid, user_lookup.name AS punished_name, reason, banned_by_uuid, banned_by_name,
removed_by_name, time, until, removed_by_reason
FROM ${tableName} AS punishment
INNER JOIN user_lookup ON user_lookup.uuid = punishment.uuid
@@ -66,7 +69,8 @@ public interface UUIDHistoryMapper {
@Param("limit") int limit,
@Param("offset") int offset);
default List<HistoryRecord> getRecent(HistoryType historyType, UserType userType, UUID uuid, int page) {
default List<HistoryRecord> getRecent(@NotNull HistoryType historyType, @NotNull UserType userType,
@NotNull UUID uuid, int page) {
return switch (historyType) {
case ALL -> getRecentAll(userType, uuid, page);
case BAN -> getRecentBans(userType, uuid, page);
@@ -76,7 +80,12 @@ public interface UUIDHistoryMapper {
};
}
private List<HistoryRecord> getRecent(String tableName, UserType userType, UUID uuid, int page) {
default List<HistoryRecord> getAllHistoryForUUID(@NotNull UUID uuid) {
return getRecentAllHistory(uuid.toString(), "uuid", 100, 0);
}
private List<HistoryRecord> getRecent(@NotNull String tableName, @NotNull UserType userType,
@NotNull UUID uuid, int page) {
int offset = page * PAGE_SIZE;
int limit = PAGE_SIZE;
return switch (userType) {
@@ -85,7 +94,7 @@ public interface UUIDHistoryMapper {
};
}
private List<HistoryRecord> getRecentAll(UserType userType, UUID uuid, int page) {
private List<HistoryRecord> getRecentAll(@NotNull UserType userType, @NotNull UUID uuid, int page) {
int offset = page * PAGE_SIZE;
int limit = PAGE_SIZE;
return switch (userType) {
@@ -96,23 +105,24 @@ public interface UUIDHistoryMapper {
};
}
private List<HistoryRecord> getRecentBans(UserType userType, UUID uuid, int page) {
private @NotNull List<HistoryRecord> getRecentBans(@NotNull UserType userType, @NotNull UUID uuid, int page) {
return addType(getRecent("litebans_bans", userType, uuid, page), "ban");
}
private List<HistoryRecord> getRecentKicks(UserType userType, UUID uuid, int page) {
private @NotNull List<HistoryRecord> getRecentKicks(@NotNull UserType userType, @NotNull UUID uuid, int page) {
return addType(getRecent("litebans_kicks", userType, uuid, page), "kick");
}
private List<HistoryRecord> getRecentMutes(UserType userType, UUID uuid, int page) {
private @NotNull List<HistoryRecord> getRecentMutes(@NotNull UserType userType, @NotNull UUID uuid, int page) {
return addType(getRecent("litebans_mutes", userType, uuid, page), "mute");
}
private List<HistoryRecord> getRecentWarns(UserType userType, UUID uuid, int page) {
private @NotNull List<HistoryRecord> getRecentWarns(@NotNull UserType userType, @NotNull UUID uuid, int page) {
return addType(getRecent("litebans_warnings", userType, uuid, page), "warn");
}
private List<HistoryRecord> addType(List<HistoryRecord> historyRecords, String type) {
@Contract("_, _ -> param1")
private @NotNull List<HistoryRecord> addType(@NotNull List<HistoryRecord> historyRecords, @NotNull String type) {
historyRecords.forEach(historyRecord -> historyRecord.setType(type));
return historyRecords;
}
@@ -0,0 +1,6 @@
package com.alttd.altitudeweb.database.luckperms;
import java.util.UUID;
public record PlayerWithGroup(String username, UUID uuid, String group) {
}
@@ -15,7 +15,24 @@ public interface TeamMemberMapper {
SELECT players.username, players.uuid
FROM luckperms_user_permissions AS permissions
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
WHERE permission = #{groupPermission}
WHERE permission = #{groupPermission} AND server = 'global'
AND world = 'global'
""")
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,13 @@
package com.alttd.altitudeweb.database.luckperms;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface UUIDUsernameMapper {
@Select("""
SELECT username
FROM luckperms_players
WHERE uuid = #{uuid}
""")
String getUsernameFromUUID(@Param("uuid") String uuid);
}
@@ -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) {
}
@@ -0,0 +1,28 @@
package com.alttd.altitudeweb.database.votingplugin;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
import java.util.UUID;
public interface VotingPluginUsersMapper {
@Select("""
SELECT
LastVotes as lastVotes,
BestDayVoteStreak as bestDailyStreak,
BestWeekVoteStreak as bestWeeklyStreak,
BestMonthVoteStreak as bestMonthlyStreak,
DayVoteStreak as dailyStreak,
WeekVoteStreak as weeklyStreak,
MonthVoteStreak as monthlyStreak,
DailyTotal as totalVotesToday,
WeeklyTotal as totalVotesThisWeek,
MonthTotal as totalVotesThisMonth,
AllTimeTotal as totalVotesAllTime
FROM votingplugin.VotingPlugin_Users
WHERE uuid = #{uuid}
""")
Optional<VotingStatsRow> getStatsByUuid(@Param("uuid") UUID uuid);
}
@@ -0,0 +1,15 @@
package com.alttd.altitudeweb.database.votingplugin;
public record VotingStatsRow(
String lastVotes,
Integer bestDailyStreak,
Integer bestWeeklyStreak,
Integer bestMonthlyStreak,
Integer dailyStreak,
Integer weeklyStreak,
Integer monthlyStreak,
Integer totalVotesToday,
Integer totalVotesThisWeek,
Integer totalVotesThisMonth,
Integer totalVotesAllTime
) { }
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.database.web_db;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.time.Instant;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class KeyPairEntity {
private int id;
private String privateKey;
private String publicKey;
private Instant createdAt;
}
@@ -0,0 +1,20 @@
package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.*;
public interface KeyPairMapper {
@Select("""
SELECT id, private_key AS privateKey, public_key AS publicKey, created_at AS createdAt
FROM key_pair
ORDER BY id
DESC LIMIT 1
""")
KeyPairEntity getKeyPair();
@Insert("""
INSERT INTO key_pair (id, private_key, public_key, created_at)
VALUES (#{id}, #{privateKey}, #{publicKey}, #{createdAt})
""")
void save(KeyPairEntity keyPair);
}
@@ -0,0 +1,17 @@
package com.alttd.altitudeweb.database.web_db;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.UUID;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PrivilegedUser {
private Integer id;
private UUID uuid;
private List<String> permissions;
}
@@ -0,0 +1,110 @@
package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.*;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface PrivilegedUserMapper {
/**
* Retrieves a user by their UUID along with their permissions
* @param uuid The UUID of the user to retrieve
* @return The optional PrivilegedUser with their permissions
*/
@Select("""
SELECT id, uuid
FROM privileged_users
WHERE uuid = #{uuid}
""")
@Results({
@Result(property = "id", column = "id"),
@Result(property = "uuid", column = "uuid"),
@Result(property = "permissions", column = "id", javaType = List.class,
many = @Many(select = "getPermissionsForUser"))
})
Optional<PrivilegedUser> getUserByUuid(@Param("uuid") UUID uuid);
/**
* Retrieves all privileged users with their permissions
* @return List of all privileged users with their permissions
*/
@Select("""
SELECT id, uuid
FROM privileged_users
""")
@Results({
@Result(property = "id", column = "id"),
@Result(property = "uuid", column = "uuid"),
@Result(property = "permissions", column = "id", javaType = List.class,
many = @Many(select = "getPermissionsForUser"))
})
List<PrivilegedUser> getAllUsers();
/**
* Gets all permissions for a specific user
* @param userId The ID of the user
* @return List of permission strings
*/
@Select("""
SELECT privileges
FROM privileges
WHERE user_id = #{userId}
""")
List<String> getPermissionsForUser(@Param("userId") int userId);
/**
* Adds a new privileged user
* @param user The PrivilegedUser object to add
* @return The number of rows affected
*/
@Insert("""
INSERT INTO privileged_users (uuid)
VALUES (#{user.uuid})
""")
@Options(useGeneratedKeys = true, keyProperty = "user.id", keyColumn = "id")
int addUser(@Param("user") PrivilegedUser user);
/**
* Deletes a privileged user by their UUID
* @param uuid The UUID of the user to delete
* @return The number of rows affected
*/
@Delete("""
DELETE FROM privileged_users
WHERE uuid = #{uuid}
""")
int deleteUserByUuid(@Param("uuid") String uuid);
/**
* Adds a permission to a user
* @param userId The ID of the user
* @param permission The permission to add
* @return The number of rows affected
*/
@Insert("""
INSERT INTO privileges (user_id, privileges)
VALUES (#{userId}, #{permission})
""")
int addPermissionToUser(@Param("userId") int userId, @Param("permission") String permission);
/**
* Removes a permission from a user
* @param userId The ID of the user
* @param permission The permission to remove
* @return The number of rows affected
*/
@Delete("""
DELETE FROM privileges
WHERE user_id = #{userId} AND privileges = #{permission}
""")
int removePermissionFromUser(@Param("userId") int userId, @Param("permission") String permission);
@Insert("""
INSERT INTO privileged_users (uuid)
VALUES (#{user.uuid})
""")
@SelectKey(statement = "SELECT LAST_INSERT_ID()", keyProperty = "user.id", before = false, resultType = int.class)
void createPrivilegedUser(@Param("user") PrivilegedUser user);
}
@@ -3,6 +3,6 @@ package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.Select;
public interface SettingsMapper {
@Select("SELECT host, port, name, username, password FROM db_connection_settings WHERE name = #{database}")
@Select("SELECT host, port, name, username, password FROM db_connection_settings WHERE internal_name = #{database}")
DatabaseSettings getSettings(String database);
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.database.web_db.chat_session;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.time.Instant;
import java.util.UUID;
@Getter
@Builder
public class ChatSession {
private final UUID uuid;
private final Instant session_start;
private final Instant session_end;
}
@@ -0,0 +1,13 @@
package com.alttd.altitudeweb.database.web_db.chat_session;
import org.apache.ibatis.annotations.Insert;
public interface ChatSessionMapper {
@Insert("""
INSERT INTO chat_session (uuid, session_start, session_end)
VALUES (#{uuid}, #{session_start}, #{session_end})
""")
void storeSession(ChatSession chatSession);
}

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