195 Commits
Author SHA1 Message Date
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
Peter 5759d6c0dc Merge remote-tracking branch 'origin/master' 2025-04-18 21:54:56 +02:00
Peter 96cc4610dd Add pagination display and update button styling
Introduce a page number display to the pagination controls for better navigation transparency. Update button styles to use CSS variables for improved theme consistency and maintainability.
2025-04-18 21:54:36 +02:00
303 changed files with 14442 additions and 806 deletions
+1
View File
@@ -90,3 +90,4 @@ frontend/src/api
generated
liquibase*.properties
node
+28 -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,23 @@ 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")
//AOP
implementation("org.aspectj:aspectjrt:1.9.19")
@@ -42,6 +50,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 +68,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")
}
@@ -5,13 +5,13 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@SpringBootApplication
@SpringBootApplication(scanBasePackages = {"com.alttd.altitudeweb"})
@EnableAspectJAutoProxy
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,137 @@
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.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 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;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(
auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/form/**").authenticated()
.requestMatchers("/api/login/getUsername").authenticated()
.requestMatchers("/api/mail/**").authenticated()
.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())
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/login/userLogin/**").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,42 @@
package com.alttd.altitudeweb.controllers.data_from_auth;
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.UUID;
@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)) {
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");
}
}
}
@@ -0,0 +1,138 @@
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.DiscordAppealDto;
import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.model.MinecraftAppealDto;
import com.alttd.altitudeweb.model.UpdateMailDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.mail.AppealMail;
import com.alttd.altitudeweb.setup.Connection;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 com.alttd.altitudeweb.services.discord.AppealDiscord appealDiscord;
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
@Override
public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
}
@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,19 @@
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.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@@ -19,19 +22,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 +52,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 +74,7 @@ public class HistoryApiController implements HistoryApi {
historyRecords.completeExceptionally(e);
}
});
return mapPunishmentHistory(punishmentHistory, historyRecords);
return mapPunishmentHistory(punishmentHistoryList, historyRecords);
}
@Override
@@ -153,6 +159,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,16 +206,23 @@ 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;
PunishmentHistoryDto innerDto = mapPunishmentHistory(historyRecord);
punishmentHistoryList.add(innerDto);
});
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());
};
PunishmentHistoryInnerDto innerDto = new PunishmentHistoryInnerDto()
return new PunishmentHistoryDto()
.uuid(historyRecord.getUuid())
.username(historyRecord.getPunishedName())
.reason(historyRecord.getReason())
@@ -181,9 +232,111 @@ public class HistoryApiController implements HistoryApi {
.punishmentTime(historyRecord.getTime())
.expiryTime(historyRecord.getUntil())
.removedReason(historyRecord.getRemovedByReason())
.type(type);
punishmentHistory.add(innerDto);
.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<>();
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);
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
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);
}
});
return ResponseEntity.ok().body(punishmentHistory);
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<>();
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);
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
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<>();
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;
}
UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
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,251 @@
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> privilegedUser = mapper
.getUserByUuid(uuid);
if (privilegedUser.isEmpty()) {
int privilegedUserId = mapper.createPrivilegedUser(uuid);
privilegedUserCompletableFuture.complete(
Optional.of(new PrivilegedUser(privilegedUserId, uuid, List.of())));
} else {
privilegedUserCompletableFuture.complete(privilegedUser);
}
} 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,176 @@
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 authorization, String filename) throws Exception {
if (authorization == null || !authorization.equals(loginSecret)) {
return ResponseEntity.status(401).build();
}
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();
}
File targetFile = new File(file, filename);
return getFileForDownload(targetFile, filename);
}
@Override
public ResponseEntity<Resource> downloadFileForUser(String authorization, String uuid, String filename) throws Exception {
if (authorization == null || !authorization.equals(loginSecret)) {
return ResponseEntity.status(401).build();
}
File file = new File(particlesFilePath);
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) {
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) throws Exception {
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();
}
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
notifyServerOfFileUpload(filename);
return voidResponseEntity;
}
@Override
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) throws Exception {
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());
}
}
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
notifyServerOfFileUpload(uuid, filename);
return voidResponseEntity;
}
private void notifyServerOfFileUpload(String filename) {
String notificationUrl = String.format("%s/notify/%s.json", notificationServerUrl, filename);
sendNotification(notificationUrl, String.format("file upload: %s", filename));
}
private void notifyServerOfFileUpload(String uuid, String filename) {
String notificationUrl = String.format("%s/notify/%s/%s.json", notificationServerUrl, uuid, filename);
sendNotification(notificationUrl, String.format("file upload for user %s: %s", uuid, filename));
}
private void sendNotification(String notificationUrl, String logDescription) {
try {
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response = restTemplate.getForEntity(notificationUrl, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
log.info("Successfully notified server of {}", logDescription);
} else {
log.warn("Failed to notify server of {}, status: {}",
logDescription, response.getStatusCode());
}
} catch (Exception e) {
log.error("Error notifying server of {}", logDescription, e);
}
}
private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) {
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,37 @@
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.VoteDataDto;
import com.alttd.altitudeweb.model.VoteStatsDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
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.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;
@Override
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,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,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,135 @@
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.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.setup.Connection;
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 AppealDiscord {
private static final String OUTPUT_TYPE = "APPEAL";
public void sendAppealToDiscord(Appeal appeal, HistoryRecord history) {
// Fetch channels
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
try {
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
.getChannelsWithOutputType(OUTPUT_TYPE);
channelsFuture.complete(channels);
} catch (Exception e) {
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
channelsFuture.complete(new ArrayList<>());
}
});
CompletableFuture<Integer> bansF = getCountAsync(HistoryType.BAN, appeal.uuid());
CompletableFuture<Integer> 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" +
"Email: " + safe(appeal.email()) + "\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
));
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();
DiscordSender.getInstance().sendEmbedToChannels(
channelIds,
"New Appeal Submitted",
description,
fields,
null,
timestamp,
null
);
}
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)'"));
}
}
@@ -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.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();
DiscordSender.getInstance().sendEmbedToChannels(
channelIds,
"New Staff Application Submitted",
"Join date: " + (application.joinDate() != null ? application.joinDate().toString() : "unknown") +
"\nSubmitted: " + formatInstant(timestamp),
fields,
null,
timestamp,
null
);
}
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)'"));
}
}
@@ -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,4 +1,4 @@
package com.alttd.altitudeweb.controllers.limits;
package com.alttd.altitudeweb.services.limits;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -26,8 +26,8 @@ public class RateLimitAspect {
private final InMemoryRateLimiterService rateLimiterService;
@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) {
@@ -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,69 @@
package com.alttd.altitudeweb.services.mail;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
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(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);
}
}
@@ -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,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,16 @@ 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=${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
@@ -0,0 +1,124 @@
<!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}">Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.</p>
</div>
</div>
</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>
+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,9 @@ import lombok.Getter;
public enum Databases {
DEFAULT("web_db"),
LUCK_PERMS("luckperms"),
LITE_BANS("litebans");
LITE_BANS("litebans"),
DISCORD("discordLink"),
VOTING_PLUGIN("votingplugin");
private final String internalName;
@@ -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 -> throw new IllegalArgumentException("WARN has no 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;
}
@@ -16,6 +16,7 @@ public interface TeamMemberMapper {
FROM luckperms_user_permissions AS permissions
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
WHERE permission = #{groupPermission}
AND world = 'global'
""")
List<Player> getTeamMembers(@Param("groupPermission") String groupPermission);
}
@@ -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,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,111 @@
package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.*;
import org.jetbrains.annotations.Nullable;
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 (#{uuid})
""")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int createPrivilegedUser(UUID uuid);
}
@@ -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.forms;
import java.time.Instant;
import java.util.UUID;
public record Appeal(
UUID id,
UUID uuid,
String historyType,
Integer historyId,
String username,
String reason,
Instant createdAt,
Instant sendAt,
String email,
Long assignedTo
) {
}
@@ -0,0 +1,37 @@
package com.alttd.altitudeweb.database.web_db.forms;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.UUID;
public interface AppealMapper {
@Insert("""
INSERT INTO appeals (uuid, username, historyType, historyId, reason, created_at, send_at, e_mail, assigned_to)
VALUES (#{uuid}, #{username}, #{historyType}, #{historyId}, #{reason}, #{createdAt}, #{sendAt}, #{email}, #{assignedTo})
""")
void createAppeal(Appeal appeal);
@Select("""
SELECT id, uuid, historyType, historyId, reason, created_at AS createdAt, send_at AS sendAt, e_mail AS email, assigned_to AS assignedTo
FROM appeals
WHERE id = #{id}
""")
Appeal getAppealById(int id);
@Select("""
SELECT id, uuid, historyType, historyId, reason, created_at AS createdAt, send_at AS sendAt, e_mail AS email, assigned_to AS assignedTo
FROM appeals
WHERE uuid = #{uuid}
""")
List<Appeal> getAppealsByUuid(String uuid);
@Update("""
UPDATE appeals SET send_at = NOW()
WHERE id = #{id}
""")
void markAppealAsSent(UUID id);
}
@@ -0,0 +1,28 @@
package com.alttd.altitudeweb.database.web_db.forms;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
public record StaffApplication(
UUID id,
UUID uuid,
String email,
Integer age,
String discordUsername,
Boolean meetsRequirements,
String pronouns,
LocalDate joinDate,
Integer weeklyPlaytime,
String availableDays,
String availableTimes,
String previousExperience,
String pluginExperience,
String moderatorExpectations,
String additionalInfo,
Instant createdAt,
Instant sendAt,
Long assignedTo
) {
}
@@ -0,0 +1,29 @@
package com.alttd.altitudeweb.database.web_db.forms;
import org.apache.ibatis.annotations.*;
import java.util.UUID;
public interface StaffApplicationMapper {
@Insert("""
INSERT INTO staff_applications (
id, uuid, email, age, discord_username, meets_requirements, pronouns, join_date,
weekly_playtime, available_days, available_times, previous_experience, plugin_experience,
moderator_expectations, additional_info, created_at, send_at, assigned_to
) VALUES (
#{id}, #{uuid}, #{email}, #{age}, #{discordUsername}, #{meetsRequirements}, #{pronouns}, #{joinDate},
#{weeklyPlaytime},
#{availableDays},
#{availableTimes}, #{previousExperience}, #{pluginExperience},
#{moderatorExpectations}, #{additionalInfo}, #{createdAt}, #{sendAt}, #{assignedTo}
)
""")
void insert(StaffApplication application);
@Update("""
UPDATE staff_applications SET send_at = NOW()
WHERE id = #{id}
""")
void markAsSent(@Param("id") UUID id);
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.database.web_db.mail;
import java.time.Instant;
import java.util.UUID;
public record EmailVerification(
UUID id,
UUID userUuid,
String email,
String verificationCode,
boolean verified,
Instant createdAt,
Instant verifiedAt,
Instant lastSentAt
) {
}
@@ -0,0 +1,58 @@
package com.alttd.altitudeweb.database.web_db.mail;
import org.apache.ibatis.annotations.*;
import java.time.Instant;
import java.util.UUID;
public interface EmailVerificationMapper {
@Insert("""
INSERT INTO user_emails (id, user_uuid, email, verification_code, verified, created_at, last_sent_at)
VALUES (#{id}, #{userUuid}, #{email}, #{verificationCode}, #{verified}, #{createdAt}, #{lastSentAt})
""")
void insert(EmailVerification emailVerification);
@Select("""
SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified,
created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt
FROM user_emails
WHERE user_uuid = #{userUuid} AND email = #{email}
""")
EmailVerification findByUserAndEmail(@Param("userUuid") UUID userUuid, @Param("email") String email);
@Select("""
SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified,
created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt
FROM user_emails
WHERE user_uuid = #{userUuid} AND verification_code = #{code}
ORDER BY created_at DESC LIMIT 1
""")
EmailVerification findByUserAndCode(@Param("userUuid") UUID userUuid, @Param("code") String code);
@Select("""
SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified,
created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt
FROM user_emails
WHERE user_uuid = #{userUuid}
ORDER BY created_at ASC
""")
java.util.List<EmailVerification> findAllByUser(@Param("userUuid") UUID userUuid);
@Update("""
UPDATE user_emails SET verified = 1, verified_at = #{verifiedAt}
WHERE id = #{id}
""")
void markVerified(@Param("id") UUID id, @Param("verifiedAt") Instant verifiedAt);
@Update("""
UPDATE user_emails SET verification_code = #{code}, last_sent_at = #{lastSentAt}, verified = 0, verified_at = NULL
WHERE id = #{id}
""")
void updateCodeAndLastSent(@Param("id") UUID id, @Param("code") String code, @Param("lastSentAt") Instant lastSentAt);
@Delete("""
DELETE FROM user_emails WHERE user_uuid = #{userUuid} AND email = #{email}
""")
void deleteByUserAndEmail(@Param("userUuid") UUID userUuid, @Param("email") String email);
}
@@ -3,6 +3,7 @@ package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.DatabaseSettings;
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
import com.alttd.altitudeweb.type_handler.UUIDTypeHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.datasource.pooled.PooledDataSource;
import org.apache.ibatis.mapping.Environment;
@@ -11,9 +12,12 @@ import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Consumer;
@Slf4j
@@ -33,6 +37,8 @@ public class Connection {
InitializeWebDb.init();
InitializeLiteBans.init();
InitializeLuckPerms.init();
InitializeDiscord.init();
InitializeVotingPlugin.init();
}
@FunctionalInterface
@@ -54,25 +60,46 @@ public class Connection {
if (database == Databases.DEFAULT) {
return loadDefaultDatabase(addMappers);
}
CompletableFuture<DatabaseSettings> settingsFuture = new CompletableFuture<>();
getConnection(Databases.DEFAULT, (mapper -> mapper.addMapper(SettingsMapper.class))).thenApply(connection -> {
log.debug("Loading settings for database {}", database.getInternalName());
CompletableFuture<DatabaseSettings> settingsFuture = new CompletableFuture<>();
getConnection(Databases.DEFAULT, (mapper -> mapper.addMapper(SettingsMapper.class))).thenApply(connection -> {
connection.runQuery(session -> {
log.debug("Running query to load settings for database");
try {
log.debug("Running query to load settings for database {}", database.getInternalName());
DatabaseSettings loadedSettings = session.getMapper(SettingsMapper.class).getSettings(database.getInternalName());
if (loadedSettings == null) {
log.error("Failed to load settings for database {}", database.getInternalName());
}
log.debug("Loaded settings {}", loadedSettings);
log.error("Failed to load settings for database {}. No settings found in db_connection_settings table.",
database.getInternalName());
settingsFuture.completeExceptionally(new IllegalStateException(
"Database settings for " + database.getInternalName() + " not found in db_connection_settings table"));
} else {
log.debug("Loaded settings for database {}: host={}, port={}, name={}",
database.getInternalName(), loadedSettings.host(), loadedSettings.port(), loadedSettings.name());
settingsFuture.complete(loadedSettings);
}
} catch (Exception e) {
log.error("Error occurred while loading database settings for {}", database.getInternalName(), e);
settingsFuture.completeExceptionally(e);
}
});
return null;
}).exceptionally(ex -> {
log.error("Failed to access DEFAULT database to load settings for {}", database.getInternalName(), ex);
settingsFuture.completeExceptionally(ex);
return null;
});
return settingsFuture.thenApply(loadedSettings -> {
log.debug("Storing connection for database {}", database.getInternalName());
Connection connection = new Connection(loadedSettings, addMappers);
connections.put(database, connection);
return connection;
}).exceptionally(ex -> {
log.error("Failed to create connection for database {}", database.getInternalName(), ex);
throw new CompletionException("Failed to initialize database connection for " + database.getInternalName(), ex);
});
}
@@ -87,6 +114,7 @@ public class Connection {
log.debug("Loaded default database settings {}", databaseSettings);
Connection connection = new Connection(databaseSettings, addMappers);
log.debug("Created default database connection {}", connection);
connections.put(Databases.DEFAULT, connection);
return CompletableFuture.completedFuture(connection);
}
@@ -96,26 +124,66 @@ public class Connection {
sqlSessionFactory = createSqlSessionFactory(settings, addMappers);
}
try (SqlSession session = sqlSessionFactory.openSession()) {
SqlSession session = null;
try {
session = sqlSessionFactory.openSession();
consumer.accept(session);
session.commit();
} catch (Exception e) {
if (session != null) {
session.rollback();
}
log.error("Failed to run query", e);
} finally {
if (session != null) {
session.close();
}
}
}).start();
}
private SqlSessionFactory createSqlSessionFactory(DatabaseSettings settings, AddMappers addMappers) {
PooledDataSource dataSource = new PooledDataSource();
dataSource.setDriver("com.mysql.cj.jdbc.Driver");
dataSource.setUrl(String.format("jdbc:mysql://%s:%d/%s", settings.host(),
settings.port(), settings.name()));
dataSource.setUsername(settings.username());
dataSource.setPassword(settings.password());
Environment environment = new Environment("production", new JdbcTransactionFactory(), dataSource);
Configuration configuration = new Configuration(environment);
try {
Configuration configuration = getConfiguration(settings);
configuration.getTypeHandlerRegistry().register(UUID.class, UUIDTypeHandler.class);
addMappers.apply(configuration);
return new SqlSessionFactoryBuilder().build(configuration);
} catch (Exception e) {
log.error("""
Failed to create sql session factory with
\thost {}
\tport: {}
\tname: {}
\tusername: {}
""", settings.host(), settings.port(), settings.name(), settings.username(), e);
throw e;
}
}
private static @NotNull Configuration getConfiguration(DatabaseSettings settings) {
PooledDataSource dataSource = new PooledDataSource();
dataSource.setDriver("com.mysql.cj.jdbc.Driver");
String url = String.format(
"jdbc:mysql://%s:%d/%s?useSSL=true&tcpKeepAlive=true&socketTimeout=60000&connectTimeout=10000&autoReconnect=false&useUnicode=true&characterEncoding=utf8",
settings.host(),
settings.port(),
settings.name()
);
dataSource.setUrl(url);
dataSource.setUsername(settings.username());
dataSource.setPassword(settings.password());
dataSource.setPoolMaximumActiveConnections(10);
dataSource.setPoolMaximumIdleConnections(5);
dataSource.setPoolTimeToWait(20000);
dataSource.setPoolPingEnabled(true);
dataSource.setPoolPingQuery("SELECT 1");
dataSource.setPoolPingConnectionsNotUsedFor(300000); // 5 min
Environment environment = new Environment("production", new JdbcTransactionFactory(), dataSource);
return new Configuration(environment);
}
}
@@ -0,0 +1,19 @@
package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class InitializeDiscord {
protected static void init() {
log.info("Initializing Discord");
Connection.getConnection(Databases.DISCORD, (configuration) -> {
configuration.addMapper(OutputChannelMapper.class);
}).join();
log.debug("Initialized Discord");
}
}
@@ -18,6 +18,8 @@ public class InitializeLiteBans {
configuration.addMapper(NameHistoryMapper.class);
configuration.addMapper(UUIDHistoryMapper.class);
configuration.addMapper(HistoryCountMapper.class);
configuration.addMapper(IdHistoryMapper.class);
configuration.addMapper(EditHistoryMapper.class);
}).join()
.runQuery(sqlSession -> {
createAllPunishmentsView(sqlSession);
@@ -29,15 +31,15 @@ public class InitializeLiteBans {
private static void createAllPunishmentsView(SqlSession sqlSession) {
String query = """
CREATE VIEW IF NOT EXISTS all_punishments AS
SELECT uuid, reason, banned_by_uuid, banned_by_name, removed_by_name, time, until, removed_by_reason,
SELECT id, uuid, reason, banned_by_uuid, banned_by_name, removed_by_name, time, until, removed_by_reason,
'ban' as type
FROM litebans_bans
UNION ALL
SELECT uuid, reason, banned_by_uuid, banned_by_name, removed_by_name, time, until, removed_by_reason,
SELECT id, uuid, reason, banned_by_uuid, banned_by_name, removed_by_name, time, until, removed_by_reason,
'mute' as type
FROM litebans_mutes
UNION ALL
SELECT uuid, reason, banned_by_uuid, banned_by_name, removed_by_name, time, until, removed_by_reason,
SELECT id, uuid, reason, banned_by_uuid, banned_by_name, removed_by_name, time, until, removed_by_reason,
'warn' as type
FROM litebans_warnings
ORDER BY time DESC;
@@ -2,6 +2,7 @@ package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@@ -11,6 +12,7 @@ public class InitializeLuckPerms {
log.info("Initializing LuckPerms");
Connection.getConnection(Databases.LUCK_PERMS, (configuration) -> {
configuration.addMapper(TeamMemberMapper.class);
configuration.addMapper(UUIDUsernameMapper.class);
}).join();
log.debug("Initialized LuckPerms");
}
@@ -0,0 +1,17 @@
package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class InitializeVotingPlugin {
protected static void init() {
log.info("Initializing VotingPlugin");
Connection.getConnection(Databases.VOTING_PLUGIN, (configuration) -> {
configuration.addMapper(VotingPluginUsersMapper.class);
}).join();
log.debug("Initialized VotingPlugin");
}
}
@@ -1,9 +1,15 @@
package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.jetbrains.annotations.NotNull;
import java.sql.SQLException;
import java.sql.Statement;
@@ -12,15 +18,28 @@ import java.sql.Statement;
public class InitializeWebDb {
protected static void init() {
log.info("Initializing LiteBans");
log.info("Initializing WebDb");
Connection.getConnection(Databases.DEFAULT, (configuration) -> {
configuration.addMapper(SettingsMapper.class);
configuration.addMapper(KeyPairMapper.class);
configuration.addMapper(PrivilegedUserMapper.class);
configuration.addMapper(AppealMapper.class);
configuration.addMapper(StaffApplicationMapper.class);
configuration.addMapper(EmailVerificationMapper.class);
}).join()
.runQuery(InitializeWebDb::createSettingsTable);
log.debug("Initialized LuckPerms");
.runQuery(sqlSession -> {
createSettingsTable(sqlSession);
createKeyTable(sqlSession);
createPrivilegedUsersTable(sqlSession);
createPrivilegesTable(sqlSession);
createAppealTable(sqlSession);
createStaffApplicationsTable(sqlSession);
createUserEmailsTable(sqlSession);
});
log.debug("Initialized WebDb");
}
private static void createSettingsTable(SqlSession sqlSession) {
private static void createSettingsTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS db_connection_settings
(
@@ -40,4 +59,128 @@ public class InitializeWebDb {
}
}
private static void createKeyTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS key_pair (
id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
private_key TEXT NOT NULL,
public_key TEXT NOT NULL,
created_at TIMESTAMP NOT NULL
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private static void createPrivilegedUsersTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS privileged_users (
id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
uuid UUID UNIQUE NOT NULL
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private static void createPrivilegesTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS privileges (
id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id int NOT NULL,
privileges VARCHAR(36) NOT NULL,
CONSTRAINT fk_privileges_user FOREIGN KEY (user_id)
REFERENCES privileged_users(id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private static void createUserEmailsTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS user_emails (
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
user_uuid UUID NOT NULL,
email VARCHAR(255) NOT NULL,
verification_code VARCHAR(16) NOT NULL,
verified BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
verified_at TIMESTAMP NULL,
last_sent_at TIMESTAMP NULL,
FOREIGN KEY (user_uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private static void createStaffApplicationsTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS staff_applications (
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
uuid UUID NOT NULL,
email VARCHAR(320) NOT NULL,
age INT NOT NULL,
discord_username VARCHAR(32) NOT NULL,
meets_requirements BOOLEAN NOT NULL,
pronouns VARCHAR(32) NULL,
join_date DATE NOT NULL,
weekly_playtime INT NOT NULL,
available_days TEXT NOT NULL,
available_times TEXT NOT NULL,
previous_experience TEXT NOT NULL,
plugin_experience TEXT NOT NULL,
moderator_expectations TEXT NOT NULL,
additional_info TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
send_at TIMESTAMP NULL,
assigned_to BIGINT UNSIGNED NULL,
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private static void createAppealTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS appeals (
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
uuid UUID NOT NULL,
historyType VARCHAR(16) NOT NULL,
historyId BIGINT UNSIGNED NOT NULL,
username VARCHAR(16) NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
send_at TIMESTAMP NULL,
e_mail TEXT NOT NULL,
assigned_to BIGINT UNSIGNED NULL,
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
plugins {
id("java")
}
group = "com.alttd.webinterface"
version = "unspecified"
repositories {
mavenCentral()
}
dependencies {
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
// JDA
implementation("net.dv8tion:JDA:6.0.0-rc.2") {
exclude("opus-java") // exclude audio
exclude("tink") // exclude audio
}
compileOnly("org.projectlombok:lombok:1.18.38")
annotationProcessor("org.projectlombok:lombok:1.18.38")
}
tasks.test {
useJUnitPlatform()
}
@@ -0,0 +1,18 @@
package com.alttd.webinterface;
import com.alttd.webinterface.bot.DiscordBotInstance;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class DiscordBot {
public static void main(String[] args) {
String discordToken = System.getProperty("DISCORD_TOKEN");
if (discordToken == null) {
log.error("Discord token not found, put it in the DISCORD_TOKEN environment variable");
System.exit(1);
}
DiscordBotInstance discordBotInstance = new DiscordBotInstance();
discordBotInstance.start(discordToken);
}
}
@@ -0,0 +1,34 @@
package com.alttd.webinterface.bot;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.requests.GatewayIntent;
@Slf4j
public class DiscordBotInstance {
@Getter
private JDA jda;
private volatile boolean ready = false;
public synchronized void start(String token) {
if (jda != null) {
return;
}
jda = JDABuilder.createDefault(token,
GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_PRESENCES,
GatewayIntent.GUILD_MESSAGES,
GatewayIntent.MESSAGE_CONTENT)
.addEventListeners(new ReadyListener(() -> {
ready = true;
}))
.build();
}
public boolean isReady() {
return ready && jda != null && jda.getStatus() == JDA.Status.CONNECTED;
}
}
@@ -0,0 +1,26 @@
package com.alttd.webinterface.bot;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.events.session.ReadyEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;
@Slf4j
@RequiredArgsConstructor
public class ReadyListener extends ListenerAdapter {
private final Runnable onReadyCallback;
@Override
public void onReady(@NotNull ReadyEvent event) {
log.info("JDA is ready. Guilds loaded: {}", event.getJDA().getGuilds().size());
if (onReadyCallback != null) {
try {
onReadyCallback.run();
} catch (Exception e) {
log.error("Error running onReady callback", e);
}
}
}
}
@@ -0,0 +1,133 @@
package com.alttd.webinterface.send_message;
import com.alttd.webinterface.bot.DiscordBotInstance;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import java.awt.*;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@Slf4j
public class DiscordSender {
private static final DiscordSender INSTANCE = new DiscordSender();
private final DiscordBotInstance botInstance = new DiscordBotInstance();
private DiscordSender() {}
public static DiscordSender getInstance() {
return INSTANCE;
}
private void ensureStarted() {
if (botInstance.getJda() != null) return;
String token = Optional.ofNullable(System.getenv("DISCORD_TOKEN"))
.orElse(System.getProperty("DISCORD_TOKEN"));
if (token == null || token.isBlank()) {
log.error("Discord token not found. Set DISCORD_TOKEN as an environment variable or system property.");
return;
}
botInstance.start(token);
}
public void sendMessageToChannels(List<Long> channelIds, String message) {
ensureStarted();
if (botInstance.getJda() == null) {
log.error("JDA not initialized; cannot send Discord message.");
return;
}
try {
if (!botInstance.isReady()) {
botInstance.getJda().awaitReady();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
log.warn("Error while waiting for JDA ready state", e);
}
channelIds.stream()
.filter(Objects::nonNull)
.forEach(id -> {
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id);
if (channel == null) {
log.warn("TextChannel with id {} not found", id);
return;
}
channel.sendMessage(message).queue(
success -> log.debug("Sent message to channel {}", id),
error -> log.error("Failed sending message to channel {}", id, error)
);
});
}
public void sendEmbedToChannels(List<Long> channelIds, String title, String description, List<EmbedField> fields,
Integer colorRgb, Instant timestamp, String footer) {
ensureStarted();
if (botInstance.getJda() == null) {
log.error("JDA not initialized; cannot send Discord embed.");
return;
}
try {
if (!botInstance.isReady()) {
botInstance.getJda().awaitReady();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
log.warn("Error while waiting for JDA ready state", e);
}
EmbedBuilder eb = new EmbedBuilder();
if (title != null && !title.isBlank()) eb.setTitle(title);
if (description != null && !description.isBlank()) eb.setDescription(description);
if (colorRgb != null) eb.setColor(new Color(colorRgb)); else eb.setColor(new Color(0xFF8C00)); // default orange
eb.setTimestamp(timestamp != null ? timestamp : Instant.now());
if (footer != null && !footer.isBlank()) eb.setFooter(footer);
if (fields != null) {
for (EmbedField f : fields) {
if (f == null) continue;
String name = f.getName() == null ? "" : f.getName();
String value = f.getValue() == null ? "" : f.getValue();
// JDA field value max is 1024; truncate to be safe
if (value.length() > 1024) value = value.substring(0, 1021) + "...";
eb.addField(new MessageEmbed.Field(name, value, f.isInline()));
}
}
MessageEmbed embed = eb.build();
channelIds.stream()
.filter(Objects::nonNull)
.forEach(id -> {
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id);
if (channel == null) {
log.warn("TextChannel with id {} not found", id);
return;
}
channel.sendMessageEmbeds(embed).queue(
success -> log.debug("Sent embed to channel {}", id),
error -> log.error("Failed sending embed to channel {}", id, error)
);
});
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class EmbedField {
private String name;
private String value;
private boolean inline;
}
}
+59 -14
View File
@@ -15,11 +15,12 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"builder": "@angular/build:application",
"options": {
"outputPath": "dist",
"outputPath": {
"base": "dist"
},
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": [
"zone.js"
],
@@ -34,33 +35,51 @@
"styles": [
"src/styles.scss"
],
"scripts": []
"scripts": [],
"browser": "src/main.ts"
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
"with": "src/environments/environment.production.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"logging": false,
"vendorChunk": false,
"buildOptimizer": true
"namedChunks": false
},
"development": {
"sourceMap": true,
"optimization": false
"optimization": false,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
},
"beta": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.beta.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
@@ -72,10 +91,10 @@
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
@@ -101,5 +120,31 @@
},
"cli": {
"analytics": false
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}
+54 -10
View File
@@ -1,14 +1,58 @@
tasks.register<Exec>("npmInstall") {
commandLine("npm.cmd", "install")
// commandLine("npm", "install")
import com.github.gradle.node.NodeExtension
import com.github.gradle.node.npm.task.NpmTask
plugins {
id("com.github.node-gradle.node") version "7.1.0"
id("base") // This adds the standard Gradle lifecycle tasks like "build"
}
tasks.register<Exec>("ngBuild") {
dependsOn("npmInstall", "generateFrontendApi")
// commandLine("npm", "run", "build")
commandLine("npm.cmd", "run", "build")
node {
download.set(true)
// Update to the version that's compatible with your environment requirements
version.set("20.19.0")
npmVersion.set("10.2.3") // A compatible npm version for Node.js 20.19.0
workDir.set(file("${project.projectDir}/node"))
npmWorkDir.set(file("${project.projectDir}/node"))
}
//dependencies {
// implementation(project(":backend"))
//}
// Clean the distribution directory
tasks.register<Delete>("cleanDist") {
description = "Clean the distribution directory"
delete("dist")
}
// Create a task that will run npm build
tasks.register<com.github.gradle.node.npm.task.NpmTask>("npmBuild") {
description = "Run 'npm run build'"
group = "build"
// Determine which build script to run based on the OS
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
npmCommand.set(listOf("run", if (isWindows) "build:dev" else "build:beta"))
dependsOn("npmInstall")
}
// Add a new task to check Node.js and npm versions
tasks.register<com.github.gradle.node.task.NodeTask>("nodeVersionCheck") {
description = "Check Node.js and npm versions"
script.set(file("${projectDir}/node-version-check.js"))
doFirst {
// Create a temporary script to check versions
file("${projectDir}/node-version-check.js").writeText("""
console.log('Node.js version:', process.version);
console.log('npm version:', require('npm/package.json').version);
console.log('Build command that would be used:', process.platform === 'win32' ? 'build:dev' : 'build:beta');
""".trimIndent())
}
doLast {
// Clean up the temporary script
delete("${projectDir}/node-version-check.js")
}
}
tasks.named("assemble") {
dependsOn("npmBuild")
}
+21 -13
View File
@@ -6,26 +6,34 @@
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
"test": "ng test",
"build:beta": "ng build --configuration=beta",
"build:prod": "ng build --configuration=production",
"build:dev": "ng build --configuration=development"
},
"private": true,
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"ngx-cookie-service": "^19.1.2",
"@angular/cdk": "^20.1.3",
"@angular/common": "^20.1.0",
"@angular/compiler": "^20.1.0",
"@angular/core": "^20.1.0",
"@angular/forms": "^20.1.0",
"@angular/material": "^20.1.3",
"@angular/platform-browser": "^20.1.0",
"@angular/platform-browser-dynamic": "^20.1.0",
"@angular/router": "^20.1.0",
"@auth0/angular-jwt": "^5.2.0",
"@types/three": "^0.177.0",
"ngx-cookie-service": "^20.0.1",
"rxjs": "~7.8.0",
"three": "^0.177.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.5",
"@angular/cli": "^19.2.5",
"@angular/compiler-cli": "^19.2.0",
"@angular/build": "^20.1.0",
"@angular/cli": "^20.1.0",
"@angular/compiler-cli": "^20.1.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
@@ -33,6 +41,6 @@
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
"typescript": "^5.8.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:8080",
"secure": false,
"changeOrigin": true
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.7955 15.8111L21 21M18 10.5C18 14.6421 14.6421 18 10.5 18C6.35786 18 3 14.6421 3 10.5C3 6.35786 6.35786 3 10.5 3C14.6421 3 18 6.35786 18 10.5Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 467 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+1 -5
View File
@@ -4,8 +4,4 @@
# New page
- ng generate component <name>
- Go to the component (<name>.component.ts)
- add standalone: false to the @Component
- remove imports: [] from that same block
- Go to app.module.ts and add the new page in declaration
- Add the new file to routes and give it a path (like 'new_page')
- Go to app.routes.ts and give it a path
+4 -4
View File
@@ -1,8 +1,8 @@
import {Component, OnInit} from '@angular/core';
import {Meta, Title} from '@angular/platform-browser';
import {ALTITUDE_VERSION} from './constant';
import {ALTITUDE_VERSION} from '@custom-types/constant';
import {Router, RouterOutlet} from '@angular/router';
import {FooterComponent} from './footer/footer.component';
import {FooterComponent} from '@pages/footer/footer/footer.component';
@Component({
standalone: true,
@@ -10,8 +10,8 @@ import {FooterComponent} from './footer/footer.component';
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
imports: [
FooterComponent,
RouterOutlet
RouterOutlet,
FooterComponent
]
})
export class AppComponent implements OnInit {
-8
View File
@@ -1,8 +0,0 @@
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({eventCoalescing: true}), provideRouter(routes)]
};
+122 -13
View File
@@ -1,50 +1,159 @@
import {Routes} from '@angular/router';
import {AuthGuard} from './guards/auth.guard';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent)
},
{
path: 'particles',
loadComponent: () => import('./pages/particles/particles.component').then(m => m.ParticlesComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_head_mod']
}
},
{
path: 'map',
loadComponent: () => import('./map/map.component').then(m => m.MapComponent)
loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent)
},
{
path: 'rules',
loadComponent: () => import('./rules/rules.component').then(m => m.RulesComponent)
loadComponent: () => import('./pages/reference/rules/rules.component').then(m => m.RulesComponent)
},
{
path: 'vote',
loadComponent: () => import('./vote/vote.component').then(m => m.VoteComponent)
loadComponent: () => import('./pages/vote/vote.component').then(m => m.VoteComponent)
},
{
path: 'about',
loadComponent: () => import('./about/about.component').then(m => m.AboutComponent)
loadComponent: () => import('./pages/altitude/about/about.component').then(m => m.AboutComponent)
},
{
path: 'socials',
loadComponent: () => import('./socials/socials.component').then(m => m.SocialsComponent)
loadComponent: () => import('./pages/altitude/socials/socials.component').then(m => m.SocialsComponent)
},
{
path: 'team',
loadComponent: () => import('./team/team.component').then(m => m.TeamComponent)
loadComponent: () => import('./pages/altitude/team/team.component').then(m => m.TeamComponent)
},
{
path: 'birthdays',
loadComponent: () => import('./birthdays/birthdays.component').then(m => m.BirthdaysComponent)
loadComponent: () => import('./pages/altitude/birthdays/birthdays.component').then(m => m.BirthdaysComponent)
},
{
path: 'terms',
loadComponent: () => import('./terms/terms.component').then(m => m.TermsComponent)
loadComponent: () => import('./pages/footer/terms/terms.component').then(m => m.TermsComponent)
},
{
path: 'privacy',
loadComponent: () => import('./privacy/privacy.component').then(m => m.PrivacyComponent)
loadComponent: () => import('./pages/footer/privacy/privacy.component').then(m => m.PrivacyComponent)
},
{
path: 'bans',
loadComponent: () => import('./bans/bans.component').then(m => m.BansComponent)
loadComponent: () => import('./pages/reference/bans/bans.component').then(m => m.BansComponent)
},
{
path: 'bans/:type/:id',
loadComponent: () => import('./pages/reference/bans/details/details.component').then(m => m.DetailsComponent)
},
{
path: 'economy',
loadComponent: () => import('./pages/features/economy/economy.component').then(m => m.EconomyComponent)
},
{
path: 'claiming',
loadComponent: () => import('./pages/features/claiming/claiming.component').then(m => m.ClaimingComponent)
},
{
path: 'mypet',
loadComponent: () => import('./pages/features/mypet/mypet.component').then(m => m.MypetComponent)
},
{
path: 'warps',
loadComponent: () => import('./pages/features/warps/warps.component').then(m => m.WarpsComponent)
},
{
path: 'skyblock',
loadComponent: () => import('./pages/features/skyblock/skyblock.component').then(m => m.SkyblockComponent)
},
{
path: 'customfeatures',
loadComponent: () => import('./pages/features/customfeatures/customfeatures.component').then(m => m.CustomfeaturesComponent)
},
{
path: 'guide',
loadComponent: () => import('./pages/reference/guide/guide.component').then(m => m.GuideComponent)
},
{
path: 'ranks',
loadComponent: () => import('./pages/reference/ranks/ranks.component').then(m => m.RanksComponent)
},
{
path: 'commandlist',
loadComponent: () => import('./pages/reference/commandlist/commandlist.component').then(m => m.CommandlistComponent)
},
{
path: 'mapart',
loadComponent: () => import('./pages/reference/mapart/mapart.component').then(m => m.MapartComponent)
},
{
path: 'lag',
loadComponent: () => import('./pages/reference/lag/lag.component').then(m => m.LagComponent)
},
{
path: 'staffpowers',
loadComponent: () => import('./pages/reference/staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
},
{
path: 'forms',
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
},
{
path: 'appeal',
redirectTo: 'forms/appeal',
pathMatch: 'full'
},
{
path: 'forms/appeal',
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_user']
}
},
{
path: 'forms/sent',
loadComponent: () => import('./pages/forms/sent/sent.component').then(m => m.SentComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_user']
}
},
{
path: 'apply',
redirectTo: 'forms/staff-application',
pathMatch: 'full'
},
{
path: 'forms/staff-application',
loadComponent: () => import('./pages/forms/staff-application/staff-application.component').then(m => m.StaffApplicationComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_user']
}
},
{
path: 'community',
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)
},
{
path: 'nicknames',
loadComponent: () => import('./pages/reference/nicknames/nicknames.component').then(m => m.NicknamesComponent)
},
{
path: 'nickgenerator',
loadComponent: () => import('./pages/reference/nickgenerator/nickgenerator.component').then(m => m.NickgeneratorComponent)
},
];
-80
View File
@@ -1,80 +0,0 @@
<ng-container>
<app-header [current_page]="'bans'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">>
<div class="title" header-content>
<h1>Minecraft Punishments</h1>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<div class="container">
<div class="columnSection">
<div class="historyButtonContainer">
<div [id]="getCurrentButtonId('all')" class="button-outer" (click)="changeHistoryPunishment('all')">
<span class="button-inner"
[ngClass]="active">All</span>
</div>
<div [id]="getCurrentButtonId('ban')" class="button-outer" (click)="changeHistoryPunishment('ban')">
<span class="button-inner"
[ngClass]="active">Bans</span>
</div>
<div [id]="getCurrentButtonId('mute')" class="button-outer" (click)="changeHistoryPunishment('mute')">
<span class="button-inner"
[ngClass]="active">Mutes</span>
</div>
<div [id]="getCurrentButtonId('warn')" class="button-outer" (click)="changeHistoryPunishment('warn')">
<span class="button-inner"
[ngClass]="active">Warnings</span>
</div>
</div>
<div class="historySearchContainer">
<input class="historySearch"
type="search"
placeholder="Search.."
[(ngModel)]="searchTerm"
(input)="filterNames()"
>
<button type="submit" class="searchButton" (click)="search()">Search</button>
<div class="search-dropdown" *ngIf="filteredNames.length > 0 && searchTerm">
<div
class="dropdown-item"
*ngFor="let name of filteredNames"
(mousedown)="selectName(name)">
{{ name }}
</div>
</div>
</div>
</div>
<div class="historyTable">
<app-history [userType]="userType" [punishmentType]="punishmentType"
[page]="page" [searchTerm]="finalSearchTerm" (pageChange)="updatePageSize($event)">
</app-history>
</div>
<div class="changePageButtons">
<button [ngClass]="{'active': buttonActive(0), 'disabled': !buttonActive(0)}"
[disabled]="!buttonActive(0)"
(click)="setPage(0)" class="historyPageButton">
First page
</button>
<button [ngClass]="{'active': buttonActive(0), 'disabled': !buttonActive(0)}"
[disabled]="!buttonActive(0)"
(click)="previousPage()" class="historyPageButton">
Previous page
</button>
<span class="pageNumber">{{ this.page }} / {{ getMaxPage() }}</span>
<button [ngClass]="{'active': buttonActive(getMaxPage()), 'disabled': !buttonActive(getMaxPage())}"
[disabled]="!buttonActive(getMaxPage())"
(click)="nextPage()" class="historyPageButton">
Next page
</button>
<button [ngClass]="{'active': buttonActive(getMaxPage()), 'disabled': !buttonActive(getMaxPage())}"
[disabled]="!buttonActive(getMaxPage())"
(click)="setPage(getMaxPage())" class="historyPageButton">
Last page
</button>
</div>
</div>
</section>
</main>
</ng-container>
@@ -1,45 +0,0 @@
<ng-container *ngIf="history.length === 0">
<p>No history found</p>
</ng-container>
<ng-container *ngIf="history.length > 0">
<table [cellSpacing]="0">
<div class="historyTableHead">
<thead>
<tr>
<th class="historyType">Type</th>
<th class="historyPlayer">Player</th>
<th class="historyPlayer">Banned By</th>
<th class="historyReason">Reason</th>
<th class="historyDate">Date</th>
<th class="historyDate">Expires</th>
</tr>
</thead>
</div>
<div>
<tbody>
<tr *ngFor="let entry of history">
<td class="historyType">{{ getType(entry) }}</td>
<td class="historyPlayer">
<div class="playerContainer">
<img class="avatar" [ngSrc]="getAvatarUrl(entry.uuid)" width="25" height="25"
alt="{{entry.username}}'s Minecraft skin">
<span class="username">{{ entry.username }}</span>
</div>
</td>
<td class="historyPlayer">
<div class="playerContainer">
<img class="avatar" [ngSrc]="getAvatarUrl(entry.punishedByUuid)" width="25" height="25"
alt="{{entry.punishedBy}}'s Minecraft skin">
<span>{{ entry.punishedBy }}</span>
</div>
</td>
<td class="historyReason">{{ entry.reason | removeTrailingPeriod }}</td>
<td class="historyDate">{{ getPunishmentTime(entry) }}</td>
<td class="historyDate">{{ getExpiredTime(entry) }}</td>
</tr>
</tbody>
</div>
</table>
</ng-container>
+54
View File
@@ -0,0 +1,54 @@
import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
import {map, Observable} from 'rxjs';
import {AuthService} from '@services/auth.service';
import {MatDialog} from '@angular/material/dialog';
import {LoginDialogComponent} from '@shared-components/login/login.component';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router,
private dialog: MatDialog
) {
}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
if (!this.authService.isAuthenticated$()) {
this.router.createUrlTree(['/']);
const dialogRef = this.dialog.open(LoginDialogComponent, {
width: '400px',
})
return dialogRef.afterClosed().pipe(
map(result => {
if (result) {
return this.router.createUrlTree([state.url]);
}
return this.router.createUrlTree(['/']);
})
);
}
const requiredAuthorizations = route.data['requiredAuthorizations'] as string[];
if (!requiredAuthorizations || requiredAuthorizations.length === 0) {
return true;
}
const userAuthorizations = this.authService.getUserAuthorizations();
const hasAccess = requiredAuthorizations.some(auth => userAuthorizations.includes(auth));
if (!hasAccess) {
return this.router.createUrlTree(['/']);
}
return true;
}
}
@@ -1,15 +1,14 @@
import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service';
import {CommonModule} from '@angular/common';
import {HeaderComponent} from '../header/header.component';
import {ScrollService} from '@services/scroll.service';
import {HeaderComponent} from '@header/header.component';
@Component({
selector: 'app-about',
standalone: true,
imports: [
CommonModule,
HeaderComponent
],
],
templateUrl: './about.component.html',
styleUrl: './about.component.scss'
})
@@ -1,15 +1,14 @@
import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service';
import {CommonModule} from '@angular/common';
import {HeaderComponent} from '../header/header.component';
import {ScrollService} from '@services/scroll.service';
import {HeaderComponent} from '@header/header.component';
@Component({
selector: 'app-birthdays',
standalone: true,
imports: [
CommonModule,
HeaderComponent
],
],
templateUrl: './birthdays.component.html',
styleUrl: './birthdays.component.scss'
})
@@ -0,0 +1,79 @@
<ng-container>
<app-header [current_page]="'community'" height="460px" background_image="/public/img/backgrounds/community.jpg"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Community</h1>
<h2>Talented people who help Altitude in more than one way.</h2>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<div class="customContainer">
<h2>Current Nitro Boosters</h2>
</div>
</section>
<section id="social" class="darkmodeSectionThree">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Social Media</h2>
</div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
<p style="text-align: center;">We're currently not looking for more people to help manage our socials.</p>
</div>
</section>
<section id="crateTeam" class="darkmodeSection">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Crate Team</h2>
</div>
</section>
<section class="darkmodeSectionThree">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Event Leaders</h2>
</div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
<p style="text-align: center;">We're currently not looking for more Event Leaders.</p>
</div>
</section>
<section class="darkmodeSection">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">Event Team</h2>
</div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
<div style="flex-direction: column;">
<p style="text-align: center;">We occasionally open applications for the event team.</p>
<p style="text-align: center;">If you're interested in joining you simply need to keep an eye on the Discord
announcements.</p>
</div>
</div>
</section>
<section class="darkmodeSectionThree">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<h2 class="sectionTitle">YouTubers & Streamers</h2>
</div>
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
<div style="flex-direction: column;">
<p style="text-align: center;"><a style="cursor: pointer;" id="reqButton">Show Requirements...</a></p>
</div>
</div>
<div id="req" class="hide" style="display: flex; justify-content: center; padding-bottom: 30px;">
<div style="flex-direction: column; justify-content: center; max-width: 800px;">
<p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Requirements:</span>
</p>
<p style="text-align: center;">You need to have at least one recent stream/video on Altitude which we can use
to gauge if your audience enjoys your content on Altitude.</p>
<br>
<p style="text-align: center;">Twitch: You need to be affiliate and get at least 5 viewers on average while
streaming on Altitude.</p>
<p style="text-align: center;">YouTube videos: You need at least 500 subs and have at least 200 views per
video within a week on average for Altitude content.</p>
<p style="text-align: center;">YouTube streamers: You need at least 500 subs and have at least 5 viewers on
average while streaming on Altitude.</p>
<br>
<p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Note:</span> Before
accepting or denying you we will watch your latest video/stream on Altitude (so keep your broadcasts public
on twitch).</p>
</div>
</div>
</section>
</main>
</ng-container>
@@ -0,0 +1,12 @@
.customContainer {
width: 80%;
max-width: 1020px;
margin: auto;
padding: 80px 0;
text-align: center;
}
.hide {
display: none !important;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CommunityComponent } from './community.component';
describe('CommunityComponent', () => {
let component: CommunityComponent;
let fixture: ComponentFixture<CommunityComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CommunityComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CommunityComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,14 @@
import {Component} from '@angular/core';
import {HeaderComponent} from "@header/header.component";
@Component({
selector: 'app-community',
imports: [
HeaderComponent
],
templateUrl: './community.component.html',
styleUrl: './community.component.scss'
})
export class CommunityComponent {
}

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