106 Commits
Author SHA1 Message Date
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
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
auto f53fc9746e Update avatar URL logic for console entries
Replaced the hardcoded console image path with a specific UUID. This ensures a consistent format when constructing avatar URLs and improves maintainability.
2025-04-18 20:47:23 +02:00
auto ecee377f01 Add API endpoints for search result counts by name and UUID
Introduced new API paths and backend logic to retrieve total punishment counts based on user search queries using names or UUIDs. Updated the frontend to utilize these endpoints and display the total search results dynamically.
2025-04-18 20:43:17 +02:00
Peter ee66bdda83 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 19:33:54 +02:00
auto 905373093c Update rate limiting headers and adjust time unit for limits
Added `Access-Control-Expose-Headers` to rate limit responses to expose retry-related headers for easier accessibility on the client side. Changed the rate limit time unit in `HistoryApiController` from seconds to minutes for more reasonable throttling.
2025-04-18 19:32:34 +02:00
auto 21f2b3e4a5 Refactor pagination logic and add error handling in history
Introduced `updatePageSize` for better page size management and added checks to prevent rapid page changes. Enhanced error handling in `history.component` with retry logic on failure. Implemented `RemoveTrailingPeriodPipe` for cleaner UI formatting.
2025-04-18 19:32:23 +02:00
auto 53f67a5075 Fix handling of non-positive expiry times in punishment history 2025-04-18 18:46:14 +02:00
Peter 87a0b9ad1b Refactor pagination buttons and adjust rate limit interval.
Updated pagination buttons with new styles and class name (`historyPageButton`) for consistency and improved UX. Modified backend rate limit interval from 1 minute to 1 second for faster request handling.
2025-04-18 18:45:40 +02:00
auto a01038e86c Refactor rate-limiting and name history queries.
Removed redundant rate-limit response headers and improved query methods for fetching punishment history. Refactored methods to handle cases where partial names are empty and added escaping for underscores in search names.
2025-04-18 18:33:47 +02:00
auto e3eaab708c Add rate-limiting mechanism with AOP for API endpoints
Introduced a rate-limiting feature using Spring AOP and a custom `RateLimit` annotation. Includes `InMemoryRateLimiterService`, `RateLimitAspect`, and related classes for controlling request limits. Applied rate limiting to specific API controllers to enhance system stability and prevent abuse.
2025-04-17 20:31:14 +02:00
Peter a3570b6833 Fix improperly formatted (input) binding in bans component
Removed excessive whitespace in the (input) binding of the search field to ensure proper functionality and readability. This change improves code cleanliness without altering behavior.
2025-04-12 23:02:28 +02:00
auto 48fd133b6d Fix pageChange emission to use history length
Modified the `ngOnChanges` method to emit the length of the history instead of the current page. This ensures accurate data is provided to parent components during changes.
2025-04-12 23:01:35 +02:00
auto 21b87fbf68 Add appeal endpoints and schema definitions
Introduce new API endpoints for handling Minecraft and Discord appeals, as well as updating appeal email addresses. Define detailed schemas for request and response payloads, supporting streamlined appeal processing across all platforms.
2025-04-12 22:51:16 +02:00
auto 3a2a54050f Add custom avatar for console entries in ban history
Updated the `getAvatarUrl` method to return a specific avatar image for console entries. This ensures better visual distinction for console-generated actions in the ban history.
2025-04-12 22:20:45 +02:00
auto a5adb031bd Add pagination handling with pageSize and pageChange EventEmitter
Introduced `pageSize` in `bans.component.ts` to manage pagination logic and updated `buttonActive` to account for page size constraints. Added `pageChange` EventEmitter in `history.component.ts` to notify the parent component of page changes. Updated the template to bind `pageChange` for dynamic page size updates.
2025-04-12 22:11:40 +02:00
auto d535dd1ba9 Reset page number on search and filter changes
Ensure the page is reset to 0 when performing a search or changing filters. This prevents issues with incorrect page states and improves navigation consistency.
2025-04-12 21:55:51 +02:00
Peter 13122559db Refactor table and pagination UI components.
Replaced inline styles with reusable classes for better maintainability. Improved pagination button styling and added transitions to the history table header for a polished user experience. Updated SCSS variables for consistent naming conventions.
2025-04-12 21:55:25 +02:00
Peter a4d8fc07e9 Updated styles and structure for history table and navigation
Refactored the history buttons to include click handlers in the outer div for better clarity and functionality. Improved the table styling in the history component by adding consistent padding, fixing column widths, and ensuring text overflow handling. Added new CSS variables for table header colors to enhance theming consistency.
2025-04-12 19:42:06 +02:00
auto 3ed5fcfc0f Add pagination controls with proper state handling in BansComponent
Introduced "First", "Previous", "Next", and "Last page" buttons with state-dependent activations for improved navigation. Refactored pagination logic to utilize a constant PAGE_SIZE and adjusted calculations for max page determination. Ensured page transitions and state updates are accurate and consistent.
2025-04-12 17:41:44 +02:00
Peter 81807b107c Enhance user and punisher avatar display in history table
Refactored the layout for player and punisher avatars by adding a container around usernames and aligning elements vertically. Updated the `getAvatarUrl` method to accept only UUID strings, streamlining its usage. Improved styling for better alignment and presentation.
2025-04-12 17:41:29 +02:00
Peter 5e75c82994 Fix inconsistency in image width and height attributes
Updated the width and height attributes of the avatar image in the history component to use consistent integer values (25) instead of "25px". This ensures proper rendering and prevents potential styling issues.
2025-04-12 17:21:50 +02:00
Peter 2383c291c8 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	frontend/src/app/bans/history/history.component.html
2025-04-12 17:21:10 +02:00
auto a70159e88d Refactor avatar URL generation into reusable method
Centralized avatar URL generation with `getAvatarUrl()` method to reduce redundancy and improve code maintainability. Updated relevant components to utilize the new method for cleaner and more consistent implementation.
2025-04-12 17:20:47 +02:00
Peter fd1115086a Update header styling and improve avatar image attributes
Adjusted the header height and title text for clarity and consistency in the bans page. Also streamlined avatar image attributes by removing unnecessary units for better HTML standards compliance.
2025-04-12 17:16:10 +02:00
Peter 6a70428c75 Updated styling and components for punishment history 2025-04-12 17:05:08 +02:00
auto 1264003a44 Adjust scroll activation threshold and reset inverseYPos.
Increased the scroll threshold for activating the header to 5 for better usability. Added a reset for `inverseYPos` to 0 when the scroll position is at the top, ensuring consistent behavior.
2025-04-12 16:51:08 +02:00
auto a1b42060b0 Revise scroll position calculation in header component
Updated the formula for calculating `inverseYPos` to use a 20% reduction approach instead of dividing by 4 and inverting. This ensures a smoother and more proportional adjustment during scrolling.
2025-04-12 16:47:17 +02:00
auto 3499f331a0 Add required fields to HistoryCount object in schema
Updated the schema to include `bans`, `mutes`, `warnings`, and `kicks` as required properties in the `HistoryCount` object. This ensures proper validation and consistency across the API.
2025-04-12 01:49:57 +02:00
auto 2b27fb6955 Add dynamic pagination and state management for history
Introduced dynamic pagination based on punishment types and counts, retrieved via a new API call. Refactored state management with a centralized `pushState` method and updated button logic in the template to reflect the changes. This improves navigation consistency and user experience.
2025-04-12 01:45:59 +02:00
auto 66d641b825 Add search state persistence and pagination for bans history
Enhanced the bans component to retain search state using the History API. Updated the history component to support paginated and filtered data loading based on user input and search terms.
2025-04-12 01:35:40 +02:00
auto 0a2338e42e Adjust widths in history component styling
Reduced the width of `.historyReason` and increased the width of `.historyDate` to improve layout alignment. These changes ensure better visual balance and accommodate content more effectively.
2025-04-12 01:02:14 +02:00
auto aa2797c51c Merge remote-tracking branch 'origin/master'
# Conflicts:
#	frontend/src/app/bans/bans.component.html
2025-04-12 00:55:53 +02:00
auto c83f95a3de Enable user search and filtering in the bans component
Added support for searching and filtering user names using `HistoryService`. Implemented a dropdown for search suggestions with dynamic filtering and selection. Updated UI and styles to accommodate the new input functionality.
2025-04-12 00:54:58 +02:00
Peter df9c3ac996 Punishment page styling 2025-04-12 00:53:20 +02:00
auto 02c69b318a Sort history query results by time in descending order.
Added `ORDER BY time DESC` to ensure that history query results are consistently returned with the most recent entries first. This update improves data clarity and usability for end-users.
2025-04-11 23:44:22 +02:00
Peter 4b5a49b82a Alignment fix 2025-04-11 23:40:45 +02:00
Peter 6526bad733 Merge remote-tracking branch 'origin/master' 2025-04-11 23:36:14 +02:00
Peter 9076f6d150 Styling and date changes on punishment page
Some styling changes for the punishments page. Also changes how the dates are displayed - they should display differently depending on what region you're in.
2025-04-11 23:35:57 +02:00
auto 44d28494e5 Add API endpoint to retrieve total punishment counts
Introduced a new API endpoint `/history/total` for fetching the total counts of bans, mutes, kicks, and warnings. Added database mapping and DTO classes to support this functionality, along with necessary schema and controller updates.
2025-04-11 23:27:20 +02:00
Peter 807569a0a1 More ban pages changes 2025-04-11 22:24:25 +02:00
auto e7fde81f49 Change page size from 100 to 10 2025-04-11 21:57:10 +02:00
Peter ce9260ede6 Added table headings and darkmode fix 2025-04-11 21:56:46 +02:00
auto 23367b6dea Fix history not loading 2025-04-11 21:37:21 +02:00
Peter 97e5d202bb Renamed and added Bans nav links 2025-04-11 21:25:18 +02:00
auto 2289b14b5a Add history page 2025-04-11 21:20:02 +02:00
auto 4b891dd672 Reworked database setup and added pagination
The database tables are now automatically created
The history lookup now uses a view for names (for simplicity and readability)
The all history lookup now uses a view combining all punishment history for efficiency
2025-04-11 18:22:07 +02:00
auto 3c6141f7ad Refactor imports in team.component.ts for conciseness
Consolidated and simplified imports from '../../api' to clean up the code. This reduces redundancy and improves readability while maintaining functionality.
2025-04-11 14:48:13 +02:00
auto 0f9761da3a Refactor history APIs and integrate LiteBans database support
Redesigned history-related APIs to streamline handling of user and UUID punishments, moving from POST to GET endpoints. Added support for LiteBans database with mappers for retrieving punishment records by name and UUID, and implemented global exception handling for better error reporting. Updated schema paths and added enums (UserType, HistoryType) and a new Gradle dependency.
2025-04-11 01:12:46 +02:00
auto 43a5dafd82 Refactor API structure and update schema references
Moved API files from `sub_api` to `schemas` for better organization. Updated schema references to use `ApiError` instead of `Error`. Adjusted build configuration to include new schema folder in the API generation process.
2025-04-10 23:16:57 +02:00
auto 2b818fc297 Add removedReason field to bans API schema 2025-04-10 23:12:20 +02:00
auto 7fa84fec81 Add "removedBy" field to bans.yml
This new field captures the name of the staff member who removed a punishment. It provides additional traceability and accountability for punishment actions.
2025-04-10 22:55:53 +02:00
auto 1585011143 Refactor API structure and endpoints for history and team.
Updated API definitions and endpoints to better support punishment history and team queries. Introduced new parameters, schemas, and operations to improve functionality, and reorganized related controllers into appropriate packages.
2025-04-10 22:46:30 +02:00
auto 2137459e9b Refactor API structure and enhance logging/debugging.
Modularized OpenAPI definitions by splitting into dedicated files for teams, bans, and errors. Improved backend logging for database connections, CORS configuration, and debugging. Updated application properties to support environment-specific CORS origins and logging levels.
2025-04-10 21:45:00 +02:00
auto 23f298b8c9 Log error when database settings fail to load 2025-04-10 21:11:26 +02:00
auto 02f21169b4 Remove unused TeamMemberDto import in TeamApiController
The TeamMemberDto import was unnecessary and not used in the code. Removing it improves code cleanliness and reduces potential confusion. This is a minor optimization with no functional changes.
2025-04-10 20:39:51 +02:00
auto f10c3ab2d3 Refactor team component to use dynamic data loading
Replaced static member entries with dynamic rendering using `getTeamMembers` for better scalability. Added caching and data deduplication logic to optimize API calls. This enhances maintainability and reduces redundancy in the team component.
2025-04-10 02:08:27 +02:00
auto d5daa8458b Refactor database structure and improve player handling.
Reorganized database-related code into a dedicated module, added mappings for UUID handling, and updated SQL queries for clarity. Enhanced team members API to use player data directly, ensuring consistency and better handling of UUIDs. Introduced new database table for connection settings and adjusted Gradle configurations for modularization.
2025-04-10 01:22:19 +02:00
auto 67a3162ae3 Added backend and fixed openapi generation 2025-04-08 22:24:24 +02:00
auto 21496baab5 Remove AppModule and enhance Team and Header components
Deleted unused `AppModule` to streamline the project structure. Enhanced the `TeamComponent` by integrating team member data from the API and updating its template. Improved the `HeaderComponent` by adding `priority` to the logo image for better performance.
2025-04-08 22:23:37 +02:00
auto c2c81de9d0 Convert components to standalone and implement route-based loading
Refactored Angular components to standalone modules, enhancing modularity and reducing dependency on `AppModule`. Updated routes to facilitate lazy loading for improved performance and maintainability. Replaced `platformBrowserDynamic` with `bootstrapApplication` for modern bootstrapping.
2025-04-08 21:45:44 +02:00
Peter 9615139554 Added styling for birthday member component
Introduce new CSS styles to ensure consistent layout and design for the birthday member section. These styles handle width, padding, text alignment, and font styling for improved visual presentation.
2025-04-06 22:10:46 +02:00
Peter e7ac2d0462 Added Birthdays page
Introduced a new BirthdaysComponent with its corresponding HTML, SCSS, and spec files. Added a route for '/birthdays' in the application and integrated it into the module and routing. This component showcases staff and previous staff birthdays.
2025-04-06 22:06:48 +02:00
auto ab3abe7f6b Refactor background-position logic in header component 2025-04-06 20:50:49 +02:00
auto 7b377a3667 Refactor team section styles by using a common CSS class
Replaced inline styles with a reusable `.teamContainer` class in the team component. This improves maintainability and ensures consistent styling across sections.
2025-04-06 20:46:57 +02:00
Peter d61a783f7f Merge remote-tracking branch 'origin/master' 2025-04-06 20:46:13 +02:00
Peter b1f2a89a2e Added voting page & header styling fix
Implemented VoteComponent to allow users to vote for the server on multiple platforms. Updated routes, styles, and headers for consistency.
2025-04-06 20:45:43 +02:00
auto b256b9f66b Enable scroll position restoration in app routing 2025-04-06 17:51:16 +02:00
auto 6f0c667bdd Revert "Add OnInit to components to scroll to top on initialization"
This reverts commit d4e231f67b.
2025-04-06 17:48:44 +02:00
auto d4e231f67b Add OnInit to components to scroll to top on initialization
Updated Privacy, Team, Terms, and About components to implement OnInit. Each component now calls the `scrollToTop` method of the `ScrollService` in the `ngOnInit` lifecycle to ensure the page starts at the top when initialized.
2025-04-06 17:47:12 +02:00
auto b4d78690d9 Add overlay gradient option to rules page header 2025-04-06 17:34:44 +02:00
auto d981b7dcff Add toggle functionality for exceptions in Rules component 2025-04-06 17:29:52 +02:00
auto d006a47cf3 Integrate ScrollService into multiple components. 2025-04-06 17:27:22 +02:00
200 changed files with 9182 additions and 372 deletions
+4
View File
@@ -87,3 +87,7 @@ package-lock.json
### MINE ### ### MINE ###
frontend/src/api frontend/src/api
generated
liquibase*.properties
node
+74
View File
@@ -0,0 +1,74 @@
plugins {
java
id("org.springframework.boot") version "3.4.4"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.alttd.altitudeweb"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
repositories {
mavenCentral()
}
dependencies {
implementation(project(":open_api"))
implementation(project(":database"))
implementation(project(":frontend"))
annotationProcessor("org.projectlombok:lombok")
implementation("com.mysql:mysql-connector-j:8.0.32")
implementation("org.mybatis:mybatis:3.5.13")
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")
//AOP
implementation("org.aspectj:aspectjrt:1.9.19")
implementation("org.aspectj:aspectjweaver:1.9.19")
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> {
useJUnitPlatform()
}
tasks.bootJar {
mainClass.set("com.alttd.altitudeweb.AltitudeWebApplication")
archiveBaseName.set("altitudeweb")
archiveClassifier.set("")
}
tasks.processResources {
dependsOn("includeFrontend")
}
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")
}
@@ -1,12 +1,16 @@
package com.alttd.altitudeweb; package com.alttd.altitudeweb;
import com.alttd.altitudeweb.setup.Connection;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@SpringBootApplication @SpringBootApplication
@EnableAspectJAutoProxy
public class AltitudeWebApplication { public class AltitudeWebApplication {
public static void main(String[] args) { public static void main(String[] args) {
Connection.initDatabases();
SpringApplication.run(AltitudeWebApplication.class, args); SpringApplication.run(AltitudeWebApplication.class, args);
} }
@@ -0,0 +1,65 @@
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 org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.http.SessionCreationPolicy;
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.web.SecurityFilterChain;
import java.security.KeyPair;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final KeyPairService keyPairService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login/userLogin/**", "/login/requestNewUserLogin/**").permitAll()
.requestMatchers("/team/**", "/history/**").permitAll()
.requestMatchers("/form/**").hasAuthority(PermissionClaimDto.USER.getValue())
.requestMatchers("/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.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();
}
}
@@ -0,0 +1,33 @@
package com.alttd.altitudeweb.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
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;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.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()) {
return requestedResource;
}
return new ClassPathResource("/static/index.html");
}
});
}
}
@@ -0,0 +1,25 @@
package com.alttd.altitudeweb.controllers;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Slf4j
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Value("${cors.allowed-origins}")
private String[] allowedOrigins;
@Override
public void addCorsMappings(CorsRegistry registry) {
log.info("Registering CORS mappings for {}", String.join(", ", allowedOrigins));
registry.addMapping("/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
}
@@ -0,0 +1,20 @@
package com.alttd.altitudeweb.controllers;
import com.alttd.altitudeweb.model.ApiErrorDto;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorDto> handleException(Exception ex) {
ApiErrorDto error = new ApiErrorDto();
error.reason("Error: " + ex.getMessage());
logger.error(ex.getMessage(), ex);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
}
@@ -0,0 +1,36 @@
package com.alttd.altitudeweb.controllers.application;
import com.alttd.altitudeweb.api.AppealsApi;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.model.AppealResponseDto;
import com.alttd.altitudeweb.model.DiscordAppealDto;
import com.alttd.altitudeweb.model.MinecraftAppealDto;
import com.alttd.altitudeweb.model.UpdateMailDto;
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.concurrent.TimeUnit;
@RestController
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class AppealController implements AppealsApi {
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
@Override
public ResponseEntity<MinecraftAppealDto> 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<AppealResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Minecraft appeals are not yet supported");
}
@Override
public ResponseEntity<AppealResponseDto> updateMail(UpdateMailDto updateMailDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Updating mail is not yet supported");
}
}
@@ -0,0 +1,232 @@
package com.alttd.altitudeweb.controllers.history;
import com.alttd.altitudeweb.api.HistoryApi;
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 lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RateLimit(limit = 30, timeValue = 10, timeUnit = TimeUnit.SECONDS)
public class HistoryApiController implements HistoryApi {
@Override
public ResponseEntity<PunishmentHistoryListDto> getHistoryForAll(String userType, String type, Integer page) {
return getHistoryForUsers(userType, type, "", page);
}
@Override
public ResponseEntity<PunishmentHistoryListDto> getHistoryForUsers(String userType, String type, String user, Integer page) {
UserType userTypeEnum = UserType.getUserType(userType);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
PunishmentHistoryListDto punishmentHistoryList = new PunishmentHistoryListDto();
CompletableFuture<List<HistoryRecord>> historyRecords = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
.runQuery(sqlSession -> {
log.debug("Loading history through name for type {}", type);
try {
List<HistoryRecord> temp = sqlSession.getMapper(NameHistoryMapper.class)
.getRecent(historyTypeEnum, userTypeEnum, user, page);
historyRecords.complete(temp);
} catch (Exception e) {
log.error("Failed to load history through name for type {}", type, e);
historyRecords.completeExceptionally(e);
}
});
return mapPunishmentHistory(punishmentHistoryList, historyRecords);
}
@Override
public ResponseEntity<PunishmentHistoryListDto> getHistoryForUuid(String userType, String type, String uuid, Integer page) {
UserType userTypeEnum = UserType.getUserType(userType);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
PunishmentHistoryListDto punishmentHistoryList = new PunishmentHistoryListDto();
CompletableFuture<List<HistoryRecord>> historyRecords = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
.runQuery(sqlSession -> {
log.debug("Loading history through uuid for type {}", type);
try {
List<HistoryRecord> temp = sqlSession.getMapper(UUIDHistoryMapper.class)
.getRecent(historyTypeEnum, userTypeEnum, UUID.fromString(uuid), page);
historyRecords.complete(temp);
} catch (Exception e) {
log.error("Failed to load history through uuid for type {}", type, e);
historyRecords.completeExceptionally(e);
}
});
return mapPunishmentHistory(punishmentHistoryList, historyRecords);
}
@Override
public ResponseEntity<List<String>> getUserNames(String userType, String type) {
UserType userTypeEnum = UserType.getUserType(userType);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<List<String>> playerGroupFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
.runQuery(sqlSession -> {
log.debug("Loading user names for type {}", type);
try {
List<String> temp = sqlSession.getMapper(RecentNamesMapper.class)
.getRecent(historyTypeEnum, userTypeEnum);
playerGroupFuture.complete(temp);
} catch (Exception e) {
log.error("Failed to load user names for type {}", type, e);
playerGroupFuture.completeExceptionally(e);
}
});
return ResponseEntity.ok().body(playerGroupFuture.join());
}
@Override
public ResponseEntity<HistoryCountDto> getTotalPunishments() {
CompletableFuture<HistoryCount> historyCountCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
.runQuery(sqlSession -> {
log.debug("Loading history count");
try {
HistoryCount punishmentCounts = sqlSession.getMapper(HistoryCountMapper.class)
.getPunishmentCounts();
historyCountCompletableFuture.complete(punishmentCounts);
} catch (Exception e) {
log.error("Failed to load history count", e);
historyCountCompletableFuture.completeExceptionally(e);
}
});
return mapHistoryCount(historyCountCompletableFuture.join());
}
@Override
public ResponseEntity<Integer> getTotalResultsForUserSearch(String userType, String type, String user) {
UserType userTypeEnum = UserType.getUserType(userType);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<Integer> searchResultCountCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
.runQuery(sqlSession -> {
log.debug("Loading name search result count");
try {
Integer punishmentCount = sqlSession.getMapper(HistoryCountMapper.class)
.getNamePunishmentCount(historyTypeEnum, userTypeEnum, user);
searchResultCountCompletableFuture.complete(punishmentCount);
} catch (Exception e) {
log.error("Failed to load history count", e);
searchResultCountCompletableFuture.completeExceptionally(e);
}
});
return ResponseEntity.ok().body(searchResultCountCompletableFuture.join());
}
@Override
public ResponseEntity<Integer> getTotalResultsForUuidSearch(String userType, String type, String uuid) {
UserType userTypeEnum = UserType.getUserType(userType);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<Integer> searchResultCountCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
.runQuery(sqlSession -> {
log.debug("Loading uuid search result count");
try {
Integer punishmentCount = sqlSession.getMapper(HistoryCountMapper.class)
.getUuidPunishmentCount(historyTypeEnum, userTypeEnum, UUID.fromString(uuid));
searchResultCountCompletableFuture.complete(punishmentCount);
} catch (Exception e) {
log.error("Failed to load history count", e);
searchResultCountCompletableFuture.completeExceptionally(e);
}
});
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());
historyCountDto.setMutes(historyCount.getMutes());
historyCountDto.setWarnings(historyCount.getWarnings());
historyCountDto.setKicks(historyCount.getKicks());
return ResponseEntity.ok().body(historyCountDto);
}
private ResponseEntity<PunishmentHistoryListDto> mapPunishmentHistory(PunishmentHistoryListDto punishmentHistoryList, CompletableFuture<List<HistoryRecord>> historyRecords) {
historyRecords.join().forEach(historyRecord -> {
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());
};
return new PunishmentHistoryDto()
.uuid(historyRecord.getUuid())
.username(historyRecord.getPunishedName())
.reason(historyRecord.getReason())
.punishedByUuid(historyRecord.getBannedByUuid())
.punishedBy(historyRecord.getBannedByName())
.removedBy(historyRecord.getRemovedByName())
.punishmentTime(historyRecord.getTime())
.expiryTime(historyRecord.getUntil())
.removedReason(historyRecord.getRemovedByReason())
.type(type)
.id(historyRecord.getId());
}
}
@@ -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,172 @@
package com.alttd.altitudeweb.controllers.login;
import com.alttd.altitudeweb.api.LoginApi;
import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.PrivilegedUser;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
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.Scheduled;
import com.alttd.altitudeweb.services.limits.RateLimit;
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
@RestController
public class LoginController implements LoginApi {
private final JwtEncoder jwtEncoder;
@Value("${login.secret:#{null}}")
private String loginSecret;
private record CacheEntry(UUID uuid, Instant expiry) {}
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
@Scheduled(fixedRate = 300000) // 5 minutes in milliseconds
private 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();
}
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()) {
return ResponseEntity.ok(key.get());
}
String loginCode = generateLoginCode(uuidFromString);
return ResponseEntity.ok(loginCode);
}
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
@Override
public ResponseEntity<String> login(String code) {
CacheEntry cacheEntry1 = new CacheEntry(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"), Instant.now().plusSeconds(TimeUnit.DAYS.toSeconds(1)));
cache.put("23232323", cacheEntry1);
if (code == null) {
return ResponseEntity.badRequest().build();
}
CacheEntry cacheEntry = cache.get(code);
if (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
String token = generateToken(cacheEntry.uuid);
cache.remove(code);
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<PrivilegedUser> privilegedUserCompletableFuture = new CompletableFuture<>();
List<PermissionClaimDto> claimList = new ArrayList<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
try {
PrivilegedUser privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class)
.getUserByUuid(uuid.toString());
privilegedUserCompletableFuture.complete(privilegedUser);
} catch (Exception e) {
log.error("Failed to load user by uuid", e);
privilegedUserCompletableFuture.completeExceptionally(e);
}
});
PrivilegedUser privilegedUser = privilegedUserCompletableFuture.join();
claimList.add(PermissionClaimDto.USER);
if (privilegedUser != null) {
privilegedUser.getPermissions().forEach(permission -> {
try {
claimList.add(PermissionClaimDto.valueOf(permission));
} catch (IllegalArgumentException e) {
log.warn("Received invalid permission claim: {}", permission);
}
});
}
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("altitudeweb")
.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,43 @@
package com.alttd.altitudeweb.controllers.team;
import com.alttd.altitudeweb.api.TeamApi;
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;
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
import com.alttd.altitudeweb.model.PlayerDto;
import com.alttd.altitudeweb.model.TeamMembersDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.MINUTES)
public class TeamApiController implements TeamApi {
@Override
public ResponseEntity<TeamMembersDto> getTeamMembers(String group) {
TeamMembersDto teamMemberDtos = new TeamMembersDto();
CompletableFuture<List<Player>> playerGroupFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LUCK_PERMS)
.runQuery(sqlSession -> {
log.debug("Loading team members for group {}", group);
try {
List<Player> players = sqlSession.getMapper(TeamMemberMapper.class).getTeamMembers("group." + group);
playerGroupFuture.complete(players);
} catch (Exception e) {
log.error("Failed to load team members for group {}", group, e);
playerGroupFuture.completeExceptionally(e);
}
});
List<Player> join = playerGroupFuture.join();
join.forEach(player -> teamMemberDtos.add(new PlayerDto(player.username(), player.uuid().toString())));
return ResponseEntity.ok().body(teamMemberDtos);
}
}
@@ -0,0 +1,36 @@
package com.alttd.altitudeweb.services.limits;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class InMemoryRateLimiterService {
private final Map<String, RequestCounter> limiters = new ConcurrentHashMap<>();
public boolean tryAcquire(String key, int limit, Duration duration) {
RequestCounter counter = limiters.computeIfAbsent(key, k -> new RequestCounter());
return counter.tryAcquire(limit, duration);
}
public Duration getNextResetTime(String key, Duration duration) {
RequestCounter counter = limiters.get(key);
if (counter == null) {
return Duration.ZERO;
}
return counter.getNextResetTime(duration);
}
public int getRemainingTokens(String key, int limit, Duration duration) {
RequestCounter counter = limiters.get(key);
if (counter == null) {
return limit;
}
return Math.max(0, limit - counter.getCurrentCount(duration));
}
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.services.limits;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int limit() default 20;
long timeValue() default 60;
TimeUnit timeUnit() default TimeUnit.SECONDS;
String key() default "";
}
@@ -0,0 +1,83 @@
package com.alttd.altitudeweb.services.limits;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method;
import java.time.Duration;
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class RateLimitAspect {
private final InMemoryRateLimiterService rateLimiterService;
@Around("""
@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) {
return joinPoint.proceed();
}
HttpServletRequest request = requestAttributes.getRequest();
HttpServletResponse response = requestAttributes.getResponse();
String clientIp = request.getRemoteAddr();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RateLimit rateLimit = method.getAnnotation(RateLimit.class);
if (rateLimit == null) {
rateLimit = method.getDeclaringClass().getAnnotation(RateLimit.class);
}
if (rateLimit == null) {
return joinPoint.proceed();
}
int limit = rateLimit.limit();
Duration duration = Duration.ofSeconds(rateLimit.timeUnit().toSeconds(rateLimit.timeValue()));
String customKey = rateLimit.key();
String key = clientIp + "-" + (customKey.isEmpty() ? method.getName() : customKey);
boolean allowed = rateLimiterService.tryAcquire(key, limit, duration);
if (allowed) {
if (response != null) {
response.setHeader("X-Rate-Limit-Limit", String.valueOf(limit));
response.setHeader("X-Rate-Limit-Remaining",
String.valueOf(rateLimiterService.getRemainingTokens(key, limit, duration)));
}
return joinPoint.proceed();
} else {
log.warn("Rate limit exceeded for IP: {}, endpoint: {}", clientIp, request.getRequestURI());
Duration nextResetTime = rateLimiterService.getNextResetTime(key, duration);
return ResponseEntity
.status(HttpStatus.TOO_MANY_REQUESTS)
.header("X-Rate-Limit-Limit", String.valueOf(limit))
.header("X-Rate-Limit-Remaining", "0")
.header("Retry-After", String.valueOf(nextResetTime.getSeconds()))
.header("Access-Control-Expose-Headers", "Retry-After, X-Rate-Limit-Limit, X-Rate-Limit-Remaining")
.body(String.format("Rate limit exceeded. Try again in %d seconds.", nextResetTime.getSeconds()));
}
}
}
@@ -0,0 +1,57 @@
package com.alttd.altitudeweb.services.limits;
import java.time.Duration;
import java.time.Instant;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
public class RequestCounter {
private final Queue<Instant> requestTimestamps = new ConcurrentLinkedQueue<>();
/**
* Attempts to acquire a permit for a request within the specified rate limit.
* This method checks if the number of requests made within the given duration
* is less than the specified limit. If the limit has not been exceeded, the
* current request is recorded and the method will return true. Otherwise, it
* will return false.
*
* @param limit the maximum number of requests allowed within the specified duration
* @param duration the time window during which requests are counted against the limit
* @return true if the request is successfully acquired without exceeding the rate limit, false otherwise
*/
public synchronized boolean tryAcquire(int limit, Duration duration) {
Instant currentTime = Instant.now();
Instant cutoffTime = currentTime.minus(duration);
while (!requestTimestamps.isEmpty() && requestTimestamps.peek().isBefore(cutoffTime)) {
requestTimestamps.poll();
}
if (requestTimestamps.size() < limit) {
requestTimestamps.add(currentTime);
return true;
}
return false;
}
public int getCurrentCount(Duration duration) {
Instant cutoffTime = Instant.now().minus(duration);
return (int) requestTimestamps.stream()
.filter(timestamp -> !timestamp.isBefore(cutoffTime))
.count();
}
public Duration getNextResetTime(Duration duration) {
if (requestTimestamps.isEmpty()) {
return Duration.ZERO;
}
Instant oldestTimestamp = requestTimestamps.peek();
Instant resetTime = oldestTimestamp.plus(duration);
Duration timeUntilReset = Duration.between(Instant.now(), resetTime);
return timeUntilReset.isNegative() ? Duration.ZERO : timeUntilReset;
}
}
@@ -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,8 @@
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}
logging.level.com.alttd.altitudeweb=INFO
@@ -0,0 +1,8 @@
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}
logging.level.com.alttd.altitudeweb=DEBUG
@@ -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://alttd.com}
login.secret=${LOGIN_SECRET:SET_TOKEN}
logging.level.com.alttd.altitudeweb=INFO
@@ -0,0 +1,13 @@
package com.alttd.altitudeweb;
//import org.junit.jupiter.api.Test;
//import org.springframework.boot.test.context.SpringBootTest;
//
//@SpringBootTest
//class AltitudeWebApplicationTests {
//
// @Test
// void contextLoads() {
// }
//
//}
-143
View File
@@ -1,143 +0,0 @@
import org.openapitools.generator.gradle.plugin.tasks.GenerateTask
plugins {
java
id("org.springframework.boot") version "3.4.4"
id("io.spring.dependency-management") version "1.1.7"
id("org.openapi.generator") version "7.12.0"
}
group = "com.alttd"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
compileOnly("org.projectlombok:lombok")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test> {
useJUnitPlatform()
}
// Generate Java API using OpenAPI Generator (Spring)
tasks.register<GenerateTask>("generateJavaApi") {
generatorName.set("spring")
inputSpec.set("$projectDir/open_api/api.yml")
val myBuildDir = layout.buildDirectory.get().asFile.absolutePath
outputDir.set("$myBuildDir/generated-sources/java")
apiPackage.set("com.alttd.api")
modelPackage.set("com.alttd.model")
configOptions.set(mapOf("interfaceOnly" to "true"))
}
// Make java compiling wait until after the API is generated
tasks.named("compileJava") {
dependsOn("generateJavaApi")
}
// Generate Angular API client using OpenAPI Generator (TypeScript-Angular)
tasks.register<GenerateTask>("generateFrontendApi") {
generatorName.set("typescript-angular")
inputSpec.set("$projectDir/open_api/api.yml")
outputDir.set("$projectDir/frontend/src/api")
configOptions.set(
mapOf(
"npmName" to "generated-api",
"supportsES6" to "true"
)
)
}
// Task to install npm dependencies in the frontend folder
tasks.register<Exec>("npmInstall") {
workingDir = file("$projectDir/frontend")
commandLine("npm.cmd", "install")
// commandLine("npm", "install")
}
tasks.named("jar") {
dependsOn("copyFrontend")
}
tasks.named("resolveMainClassName") {
dependsOn("copyFrontend") // Declare `copyFrontend` as a dependency
}
// Task to build the Angular frontend; depends on npmInstall and generateFrontendApi
tasks.register<Exec>("ngBuild") {
dependsOn("npmInstall", "generateFrontendApi")
workingDir = file("$projectDir/frontend")
// commandLine("npm", "run", "build")
commandLine("npm.cmd", "run", "build")
}
// Task to copy built frontend into "static" folder for Spring Boot
tasks.register<Copy>("copyFrontend") {
val myBuildDir = layout.buildDirectory.get().asFile.absolutePath
dependsOn("ngBuild") // Make sure frontend build runs first
from("$projectDir/frontend/dist") // Source directory for Angular build
into("$myBuildDir/resources/main/static") // Destination in Spring Boot's static folder
// Optional: Explicitly mark inputs and outputs for cache optimization
inputs.dir("$projectDir/frontend/dist")
outputs.dir("$myBuildDir/resources/main/static")
}
tasks.named("compileTestJava") {
dependsOn("copyFrontend") // Ensure `copyFrontend` runs before `compileTestJava`
}
// Ensure that the frontend is built and copied before bootJar
tasks.named("bootJar") {
dependsOn("copyFrontend") // Make bootJar depend on frontend build
}
//tasks.register("checkNpm") {
// doLast {
// // Print PATH for debugging
// val gradlePath = System.getenv("PATH")
// // Add location of npm to the PATH
// val nodePath = "C:\\Program Files\\nodejs"
// val updatedPath = "$gradlePath;$nodePath"
//
// // Check if npm is accessible
// try {
// val processBuilder = ProcessBuilder("npm", "-v")
// processBuilder.environment()["PATH"] = updatedPath // Explicitly set PATH
// val npmVersion = ProcessBuilder("npm", "-v")
// .redirectErrorStream(true) // Redirect error output to standard output
// .start()
// .inputStream
// .bufferedReader()
// .readText()
// .trim()
//
// println("npm version: $npmVersion")
// } catch (e: Exception) {
// println("Error: npm not found or not accessible. Make sure npm is added to your PATH.")
// e.printStackTrace()
// }
// }
//}
+24
View File
@@ -0,0 +1,24 @@
plugins {
id("java")
}
group = "com.alttd.altitudeweb"
version = "0.0.1-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
implementation(project(":open_api"))
compileOnly("org.projectlombok:lombok:1.18.38")
annotationProcessor("org.projectlombok:lombok:1.18.38")
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 {
useJUnitPlatform()
}
@@ -0,0 +1,7 @@
package com.alttd.altitudeweb;
public class Main {
public static void main(String[] args) {
}
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.database;
import lombok.Getter;
@Getter
public enum Databases {
DEFAULT("web_db"),
LUCK_PERMS("luckperms"),
LITE_BANS("litebans");
private final String internalName;
Databases(String internalName) {
this.internalName = internalName;
}
}
@@ -0,0 +1,11 @@
package com.alttd.altitudeweb.database.litebans;
import lombok.Data;
@Data
public class HistoryCount {
private int bans;
private int mutes;
private int warnings;
private int kicks;
}
@@ -0,0 +1,97 @@
package com.alttd.altitudeweb.database.litebans;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Arrays;
import java.util.UUID;
import java.util.stream.Collectors;
public interface HistoryCountMapper {
/**
* Gets the total count of punishments from all LiteBans tables.
*
* @return A PunishmentCount object containing counts from each table
*/
@Select({"""
SELECT
(SELECT COUNT(*) FROM litebans_bans) AS bans,
(SELECT COUNT(*) FROM litebans_mutes) AS mutes,
(SELECT COUNT(*) FROM litebans_warnings) AS warnings,
(SELECT COUNT(*) FROM litebans_kicks) AS kicks
"""
})
HistoryCount getPunishmentCounts();
@Select("""
SELECT COUNT(*)
FROM (SELECT punishment.uuid, user_lookup.name AS punished_name
FROM all_punishments AS punishment
JOIN user_lookup ON user_lookup.uuid = punishment.uuid
WHERE user_lookup.name LIKE #{partialName}
AND type IN (${typeList})) AS punishment
""")
Integer getPlayerNamePunishmentCount(@Param("typeList") String typeList,
@Param("partialName") String partialName);
@Select("""
SELECT COUNT(*) AS punishment_count
FROM (SELECT uuid, banned_by_name
FROM all_punishments
WHERE banned_by_name LIKE #{partialName}
AND type IN (${typeList})) AS punishment
""")
Integer getStaffNamePunishmentCount(@Param("typeList") String typeList,
@Param("partialName") String partialName);
default Integer getNamePunishmentCount(HistoryType historyTypeEnum, UserType userTypeEnum, String partialName) {
final String searchName = partialName.toLowerCase().replace("_", "\\_") + "%";
String[] historyType = getHistoryType(historyTypeEnum);
String historyTypeSqlList = Arrays.stream(historyType)
.map(type -> "'" + type + "'")
.collect(Collectors.joining(", "));
return switch (userTypeEnum) {
case PLAYER -> getPlayerNamePunishmentCount(historyTypeSqlList, searchName);
case STAFF -> getStaffNamePunishmentCount(historyTypeSqlList, searchName);
};
}
@Select("""
SELECT COUNT(*) AS punishment_count
FROM all_punishments
WHERE uuid = #{uuid}
AND type IN (${typeList})
""")
Integer getPlayerUuidPunishmentCount(@Param("typeList") String typeList,
@Param("uuid") String uuid);
@Select("""
SELECT COUNT(*) AS punishment_count
FROM all_punishments
WHERE banned_by_uuid = #{uuid}
AND type IN (${typeList})
""")
Integer getStaffUuidPunishmentCount(@Param("typeList") String typeList,
@Param("uuid") String uuid);
default Integer getUuidPunishmentCount(HistoryType historyTypeEnum, UserType userTypeEnum, UUID uuid) {
String[] historyType = getHistoryType(historyTypeEnum);
String historyTypeSqlList = Arrays.stream(historyType)
.map(type -> "'" + type + "'")
.collect(Collectors.joining(", "));
return switch (userTypeEnum) {
case PLAYER -> getPlayerUuidPunishmentCount(historyTypeSqlList, uuid.toString());
case STAFF -> getStaffUuidPunishmentCount(historyTypeSqlList, uuid.toString());
};
}
private String[] getHistoryType(HistoryType historyTypeEnum) {
return switch (historyTypeEnum) {
case ALL -> new String[]{"ban", "mute", "warn"};
case BAN -> new String[]{"ban"};
case MUTE -> new String[]{"mute"};
case KICK -> new String[]{"kick"};
case WARN -> new String[]{"warn"};
};
}
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.database.litebans;
import lombok.Data;
@Data
public class HistoryRecord {
private String uuid;
private int id;
private String punishedName;
private String reason;
private String bannedByUuid;
private String bannedByName;
private String removedByName;
private Long time;
private Long until;
private String removedByReason;
private String type;
}
@@ -0,0 +1,13 @@
package com.alttd.altitudeweb.database.litebans;
public enum HistoryType {
ALL,
BAN,
MUTE,
KICK,
WARN;
public static HistoryType getHistoryType(String historyType) {
return HistoryType.valueOf(historyType.toUpperCase());
}
}
@@ -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;
}
}
@@ -0,0 +1,286 @@
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;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public interface NameHistoryMapper {
int PAGE_SIZE = 10;
/**
* Retrieves a list of all types of recent punishment history records filtered based on the given parameters sorted
* in descending time order. This result does NOT contain kicks history
*
* @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
*
* @return a list of {@link HistoryRecord} objects that match the specified filters, ordered by time in descending
* order
*/
@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"),
@Result(property = "type", column = "type")
})
@Select("""
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
ON user_lookup.uuid = all_punishments.uuid
WHERE ${name_column} LIKE #{partialName}
ORDER BY time DESC
LIMIT #{limit} OFFSET #{offset}
""")
List<HistoryRecord> getRecentAllHistoryForName(@Param("partialName") String partialName,
@Param("name_column") String nameColumn,
@Param("limit") int limit,
@Param("offset") int offset);
/**
* Retrieves a list of all types of recent punishment history records sorted
* in descending time order. This result does NOT contain kicks history
*
* @param limit the maximum number of records to fetch
* @param offset the starting offset position of the result set
*
* @return a list of {@link HistoryRecord} objects that match the specified filters, ordered by time in descending
* order
*/
@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"),
@Result(property = "type", column = "type")
})
@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,
punishment.type
FROM (SELECT uuid,
id,
reason,
banned_by_uuid,
banned_by_name,
removed_by_name,
time,
until,
removed_by_reason,
type
FROM all_punishments
ORDER BY time DESC
LIMIT #{limit} OFFSET #{offset}) AS punishment
INNER JOIN user_lookup
ON user_lookup.uuid = punishment.uuid;
""")
List<HistoryRecord> getRecentAllHistory(@Param("limit") int limit,
@Param("offset") int offset);
/**
* Retrieves a list of a specific type of recent punishment history records filtered based on the provided
* 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
*
* @return a list of {@link HistoryRecord} objects that match the specified filters, sorted by time in descending
* order
*/
@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 ${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("table_name") String tableName,
@Param("partialName") String partialName,
@Param("name_column") String nameColumn,
@Param("limit") int limit,
@Param("offset") int offset);
/**
* Retrieves a list of a specific type of recent punishment history records filtered based on the provided
* 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 limit the maximum number of records to fetch
* @param offset the starting offset position of the result set
*
* @return a list of {@link HistoryRecord} objects that match the specified filters, sorted by time in descending
* order
*/
@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 id,
uuid,
reason,
banned_by_uuid,
banned_by_name,
removed_by_name,
time,
until,
removed_by_reason
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("table_name") String tableName,
@Param("limit") int limit,
@Param("offset") int offset);
default List<HistoryRecord> getRecent(HistoryType historyType, UserType userType, String partialName,
int page) {
return switch (historyType) {
case ALL -> getRecentAll(userType, partialName, page);
case BAN -> getRecentBans(userType, partialName, page);
case MUTE -> getRecentMutes(userType, partialName, page);
case KICK -> getRecentKicks(userType, partialName, page);
case WARN -> getRecentWarns(userType, partialName, page);
};
}
private List<HistoryRecord> getRecent(String tableName, UserType userType, String partialName,
int page) {
int offset = page * PAGE_SIZE;
int limit = PAGE_SIZE;
if (partialName.isEmpty()) {
return getRecentHistory(tableName, limit, offset);
} else {
final String searchName = partialName.toLowerCase().replace("_", "\\_") + "%";
return switch (userType) {
case PLAYER -> getRecentHistoryForName(tableName, searchName, "name",
limit, offset);
case STAFF -> getRecentHistoryForName(tableName, searchName, "banned_by_name",
limit, offset);
};
}
}
private List<HistoryRecord> getRecentAll(UserType userType, String partialName, int page) {
int offset = page * PAGE_SIZE;
int limit = PAGE_SIZE;
if (partialName.isEmpty()) {
return getRecentAllHistory(limit, offset);
} else {
final String searchName = partialName.toLowerCase().replace("_", "\\_") + "%";
return switch (userType) {
case PLAYER -> getRecentAllHistoryForName(searchName, "name",
limit, offset);
case STAFF -> getRecentAllHistoryForName(searchName, "banned_by_name",
limit, offset);
};
}
}
private @NotNull List<HistoryRecord> getRecentBans(UserType userType, String partialName, int page) {
return addType(getRecent("litebans_bans", userType, partialName, page), "ban");
}
private @NotNull List<HistoryRecord> getRecentKicks(UserType userType, String partialName, int page) {
return addType(getRecent("litebans_kicks", userType, partialName, page), "kick");
}
private @NotNull List<HistoryRecord> getRecentMutes(UserType userType, String partialName, int page) {
return addType(getRecent("litebans_mutes", userType, partialName, page), "mute");
}
private @NotNull List<HistoryRecord> getRecentWarns(UserType userType, String partialName, int page) {
return addType(getRecent("litebans_warnings", userType, partialName, page), "warn");
}
private @NotNull List<HistoryRecord> addType(@NotNull List<HistoryRecord> historyRecords, String type) {
historyRecords.forEach(historyRecord -> historyRecord.setType(type));
return historyRecords;
}
}
@@ -0,0 +1,68 @@
package com.alttd.altitudeweb.database.litebans;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.ArrayList;
import java.util.List;
public interface RecentNamesMapper {
@Select("""
SELECT DISTINCT user_lookup.name AS punished_name
FROM ${tableName} AS punishment
INNER JOIN user_lookup
ON user_lookup.uuid = punishment.${uuid_column}
WHERE punishment.time > UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 1 YEAR)) * 1000
ORDER BY user_lookup.name
""")
List<String> getRecentHistory(@Param("tableName") String tableName, @Param("uuid_column") String uuidColumn);
default List<String> getRecent(HistoryType historyType, UserType userType) {
return switch (historyType) {
case ALL -> getRecentAll(userType);
case BAN -> getRecentBans(userType);
case MUTE -> getRecentMutes(userType);
case KICK -> getRecentKicks(userType);
case WARN -> getRecentWarns(userType);
};
}
private List<String> getRecent(String tableName, UserType userType) {
switch (userType) {
case PLAYER -> {
return getRecentHistory(tableName, "uuid");
}
case STAFF -> {
return getRecentHistory(tableName, "banned_by_uuid");
}
default -> throw new IllegalArgumentException("Invalid user type");
}
}
private List<String> getRecentBans(UserType userType) {
return getRecent("litebans_bans", userType);
}
private List<String> getRecentKicks(UserType userType) {
return getRecent("litebans_kicks", userType);
}
private List<String> getRecentMutes(UserType userType) {
return getRecent("litebans_mutes", userType);
}
private List<String> getRecentWarns(UserType userType) {
return getRecent("litebans_warnings", userType);
}
private List<String> getRecentAll(UserType userType) {
List<String> all = new ArrayList<>();
all.addAll(getRecentBans(userType));
// Removed recent kicks as we do not show those
// all.addAll(getRecentKicks(userType));
all.addAll(getRecentMutes(userType));
all.addAll(getRecentWarns(userType));
return all;
}
}
@@ -0,0 +1,130 @@
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;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.UUID;
public interface UUIDHistoryMapper {
int PAGE_SIZE = 10;
@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"),
@Result(property = "type", column = "type")
})
@Select("""
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
ON user_lookup.uuid = all_punishments.uuid
WHERE all_punishments.${uuid_column} = #{uuid}
ORDER BY time DESC
LIMIT #{limit} OFFSET #{offset}
""")
List<HistoryRecord> getRecentAllHistory(@Param("uuid") String uuid,
@Param("uuid_column") String uuidColumn,
@Param("limit") int limit,
@Param("offset") int offset);
@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, 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
WHERE punishment.${uuid_column} = #{uuid}
ORDER BY time DESC
LIMIT #{limit} OFFSET #{offset}
""")
List<HistoryRecord> getRecentHistory(@Param("tableName") String tableName,
@Param("uuid") String uuid,
@Param("uuid_column") String uuidColumn,
@Param("limit") int limit,
@Param("offset") int offset);
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);
case MUTE -> getRecentMutes(userType, uuid, page);
case KICK -> getRecentKicks(userType, uuid, page);
case WARN -> getRecentWarns(userType, uuid, 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) {
case PLAYER -> getRecentHistory(tableName, uuid.toString(), "uuid", limit, offset);
case STAFF -> getRecentHistory(tableName, uuid.toString(), "banned_by_uuid", limit, offset);
};
}
private List<HistoryRecord> getRecentAll(@NotNull UserType userType, @NotNull UUID uuid, int page) {
int offset = page * PAGE_SIZE;
int limit = PAGE_SIZE;
return switch (userType) {
case PLAYER -> getRecentAllHistory(uuid.toString(), "name",
limit, offset);
case STAFF -> getRecentAllHistory(uuid.toString(), "banned_by_name",
limit, offset);
};
}
private @NotNull List<HistoryRecord> getRecentBans(@NotNull UserType userType, @NotNull UUID uuid, int page) {
return addType(getRecent("litebans_bans", userType, uuid, page), "ban");
}
private @NotNull List<HistoryRecord> getRecentKicks(@NotNull UserType userType, @NotNull UUID uuid, int page) {
return addType(getRecent("litebans_kicks", userType, uuid, page), "kick");
}
private @NotNull List<HistoryRecord> getRecentMutes(@NotNull UserType userType, @NotNull UUID uuid, int page) {
return addType(getRecent("litebans_mutes", userType, uuid, page), "mute");
}
private @NotNull List<HistoryRecord> getRecentWarns(@NotNull UserType userType, @NotNull UUID uuid, int page) {
return addType(getRecent("litebans_warnings", userType, uuid, page), "warn");
}
@Contract("_, _ -> param1")
private @NotNull List<HistoryRecord> addType(@NotNull List<HistoryRecord> historyRecords, @NotNull String type) {
historyRecords.forEach(historyRecord -> historyRecord.setType(type));
return historyRecords;
}
}
@@ -0,0 +1,10 @@
package com.alttd.altitudeweb.database.litebans;
public enum UserType {
PLAYER,
STAFF;
public static UserType getUserType(String userType) {
return UserType.valueOf(userType.toUpperCase());
}
}
@@ -0,0 +1,7 @@
package com.alttd.altitudeweb.database.luckperms;
import java.util.UUID;
public record Player(String username, UUID uuid) {
}
@@ -0,0 +1,22 @@
package com.alttd.altitudeweb.database.luckperms;
import com.alttd.altitudeweb.type_handler.UUIDTypeHandler;
import org.apache.ibatis.annotations.*;
import java.util.List;
import java.util.UUID;
public interface TeamMemberMapper {
@ConstructorArgs({
@Arg(column = "username", javaType = String.class),
@Arg(column = "uuid", javaType = UUID.class, typeHandler = UUIDTypeHandler.class)
})
@Select("""
SELECT players.username, players.uuid
FROM luckperms_user_permissions AS permissions
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
WHERE permission = #{groupPermission}
AND world = 'global'
""")
List<Player> getTeamMembers(@Param("groupPermission") String groupPermission);
}
@@ -0,0 +1,4 @@
package com.alttd.altitudeweb.database.web_db;
public record DatabaseSettings(String host, int port, String name, String username, String password) {
}
@@ -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,15 @@
package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.*;
public interface KeyPairMapper {
@Select("SELECT * 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,16 @@
package com.alttd.altitudeweb.database.web_db;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PrivilegedUser {
private int id;
private String uuid;
private List<String> permissions;
}
@@ -0,0 +1,102 @@
package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.*;
import java.util.List;
public interface PrivilegedUserMapper {
/**
* Retrieves a user by their UUID along with their permissions
* @param uuid The UUID of the user to retrieve
* @return The PrivilegedUser with their permissions, or null if not found
*/
@Select("""
SELECT privileged_users.id, privileged_users.uuid, privileges.privileges as permission
FROM privileged_users
LEFT JOIN privileges ON privileged_users.id = privileges.user_id
WHERE privileged_users.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"))
})
PrivilegedUser getUserByUuid(@Param("uuid") String 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);
}
@@ -0,0 +1,8 @@
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}")
DatabaseSettings getSettings(String database);
}
@@ -0,0 +1,122 @@
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 lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.datasource.pooled.PooledDataSource;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
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 java.util.HashMap;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
@Slf4j
public class Connection {
private static final HashMap<Databases, Connection> connections = new HashMap<>();
private SqlSessionFactory sqlSessionFactory;
private final DatabaseSettings settings;
private final AddMappers addMappers;
private Connection(DatabaseSettings settings, AddMappers addMappers) {
this.settings = settings;
this.addMappers = addMappers;
}
public static void initDatabases() {
InitializeWebDb.init();
InitializeLiteBans.init();
InitializeLuckPerms.init();
}
@FunctionalInterface
public interface AddMappers {
void apply(Configuration configuration);
}
public static Connection getConnection(Databases database) {
if (connections.containsKey(database)) {
return connections.get(database);
}
throw new RuntimeException("Database " + database + " has not been initialized");
}
protected static CompletableFuture<Connection> getConnection(Databases database, AddMappers addMappers) {
if (connections.containsKey(database)) {
return CompletableFuture.completedFuture(connections.get(database));
}
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());
connection.runQuery(session -> {
log.debug("Running query to load settings for database");
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);
settingsFuture.complete(loadedSettings);
});
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;
});
}
private static CompletableFuture<Connection> loadDefaultDatabase(AddMappers addMappers) {
DatabaseSettings databaseSettings = new DatabaseSettings(
System.getenv("DB_HOST"),
Integer.parseInt(System.getenv("DB_PORT")),
System.getenv("DB_NAME"),
System.getenv("DB_USER"),
System.getenv("DB_PASS")
);
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);
}
public void runQuery(Consumer<SqlSession> consumer) {
new Thread(() -> {
if (sqlSessionFactory == null) {
sqlSessionFactory = createSqlSessionFactory(settings, addMappers);
}
try (SqlSession session = sqlSessionFactory.openSession()) {
consumer.accept(session);
} catch (Exception e) {
log.error("Failed to run query", e);
}
}).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);
addMappers.apply(configuration);
return new SqlSessionFactoryBuilder().build(configuration);
}
}
@@ -0,0 +1,71 @@
package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.litebans.*;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import java.sql.SQLException;
import java.sql.Statement;
@Slf4j
public class InitializeLiteBans {
protected static void init() {
log.info("Initializing LiteBans");
Connection.getConnection(Databases.LITE_BANS, (configuration) -> {
configuration.addMapper(RecentNamesMapper.class);
configuration.addMapper(NameHistoryMapper.class);
configuration.addMapper(UUIDHistoryMapper.class);
configuration.addMapper(HistoryCountMapper.class);
configuration.addMapper(IdHistoryMapper.class);
}).join()
.runQuery(sqlSession -> {
createAllPunishmentsView(sqlSession);
createUserLookupView(sqlSession);
});
log.debug("Initialized LiteBans");
}
private static void createAllPunishmentsView(SqlSession sqlSession) {
String query = """
CREATE VIEW IF NOT EXISTS all_punishments AS
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 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 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;
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private static void createUserLookupView(SqlSession sqlSession) {
String query = """
CREATE VIEW IF NOT EXISTS user_lookup AS
SELECT history_1.uuid, history_1.name
FROM litebans.litebans_history history_1
INNER JOIN (
SELECT uuid, MAX(id) as max_id
FROM litebans.litebans_history
GROUP BY uuid
) history_2 ON history_1.uuid = history_2.uuid AND history_1.id = history_2.max_id
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class InitializeLuckPerms {
protected static void init() {
log.info("Initializing LuckPerms");
Connection.getConnection(Databases.LUCK_PERMS, (configuration) -> {
configuration.addMapper(TeamMemberMapper.class);
}).join();
log.debug("Initialized LuckPerms");
}
}
@@ -0,0 +1,100 @@
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.SettingsMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.jetbrains.annotations.NotNull;
import java.sql.SQLException;
import java.sql.Statement;
@Slf4j
public class InitializeWebDb {
protected static void init() {
log.info("Initializing WebDb");
Connection.getConnection(Databases.DEFAULT, (configuration) -> {
configuration.addMapper(SettingsMapper.class);
configuration.addMapper(KeyPairMapper.class);
}).join()
.runQuery(SqlSession -> {
createSettingsTable(SqlSession);
createKeyTable(SqlSession);
createPrivilegedUsersTable(SqlSession);
createPrivilegesTable(SqlSession);
});
log.debug("Initialized WebDb");
}
private static void createSettingsTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS db_connection_settings
(
internal_name VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
host VARCHAR(255) NOT NULL,
port INT NOT NULL,
CONSTRAINT pk_internal_name PRIMARY KEY (internal_name)
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
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 VARCHAR(36) 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);
}
}
}
@@ -0,0 +1,38 @@
package com.alttd.altitudeweb.type_handler;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedTypes;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
@MappedTypes(UUID.class)
public class UUIDTypeHandler extends BaseTypeHandler<UUID> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, UUID parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, parameter.toString());
}
@Override
public UUID getNullableResult(ResultSet rs, String columnName) throws SQLException {
String uuid = rs.getString(columnName);
return uuid == null ? null : UUID.fromString(uuid);
}
@Override
public UUID getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String uuid = rs.getString(columnIndex);
return uuid == null ? null : UUID.fromString(uuid);
}
@Override
public UUID getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String uuid = cs.getString(columnIndex);
return uuid == null ? null : UUID.fromString(uuid);
}
}
@@ -0,0 +1,10 @@
CREATE TABLE db_connection_settings
(
internal_name VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
host VARCHAR(255) NOT NULL,
port INT NOT NULL,
CONSTRAINT pk_internal_name PRIMARY KEY (internal_name)
);
+22 -4
View File
@@ -41,21 +41,39 @@
"fileReplacements": [ "fileReplacements": [
{ {
"replace": "src/environments/environment.ts", "replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts" "with": "src/environments/environment.production.ts"
} }
], ],
"optimization": true, "optimization": true,
"outputHashing": "all", "outputHashing": "all",
"sourceMap": false, "sourceMap": false,
"extractCss": true,
"namedChunks": false, "namedChunks": false,
"logging": false,
"vendorChunk": false, "vendorChunk": false,
"buildOptimizer": true "buildOptimizer": true
}, },
"development": { "development": {
"sourceMap": true, "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,
"vendorChunk": false,
"buildOptimizer": true
} }
} }
}, },
+58
View File
@@ -0,0 +1,58 @@
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"
}
node {
download.set(true)
version.set("22.14.0")
npmVersion.set("10.9.2")
workDir.set(file("${project.projectDir}/node"))
npmWorkDir.set(file("${project.projectDir}/node"))
}
// 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("npmBuild") {
description = "Run 'npm run build'"
group = "build"
doLast {
// Use nodeCommand directly from the plugin
project.exec {
workingDir(project.projectDir)
// Use node's npm to ensure it works on all environments
val nodeDir = "${project.projectDir}/node"
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
if (isWindows) {
val npmCmd = file(nodeDir).listFiles()?.find { it.name.startsWith("npm") && it.isDirectory }?.let {
"${it.absolutePath}/npm.cmd"
} ?: "$nodeDir/node_modules/npm/bin/npm.cmd"
commandLine(npmCmd, "run", "build:dev")
} else {
val npmExecutable = file(nodeDir).listFiles()?.find { it.name.startsWith("npm") && it.isDirectory }?.let {
"${it.absolutePath}/bin/npm"
} ?: "$nodeDir/node_modules/npm/bin/npm"
commandLine(npmExecutable, "run", "build:beta")
}
}
}
dependsOn("npmInstall")
}
tasks.named("assemble") {
dependsOn("npmBuild")
}
+6 -1
View File
@@ -6,14 +6,19 @@
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "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, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^19.2.18",
"@angular/common": "^19.2.0", "@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0", "@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0", "@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0", "@angular/forms": "^19.2.0",
"@angular/material": "^19.2.18",
"@angular/platform-browser": "^19.2.0", "@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0", "@angular/router": "^19.2.0",
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

+1 -5
View File
@@ -4,8 +4,4 @@
# New page # New page
- ng generate component <name> - ng generate component <name>
- Go to the component (<name>.component.ts) - Go to app.routes.ts and give it a path
- 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')
+2 -1
View File
@@ -1,5 +1,6 @@
<ng-container> <ng-container>
<app-header [current_page]="'rules'" height="460px" background_image="/public/img/backgrounds/trees.jpg"> <app-header [current_page]="'about'" height="460px" background_image="/public/img/backgrounds/trees.jpg"
[overlay_gradient]="0.5">
<div class="title" header-content> <div class="title" header-content>
<h1>About Us</h1> <h1>About Us</h1>
<h2>We are a simple server born out of nostalgia, and working to maintain a semi-vanilla feel today's Minecraft <h2>We are a simple server born out of nostalgia, and working to maintain a semi-vanilla feel today's Minecraft
+12 -2
View File
@@ -1,11 +1,21 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service';
import {CommonModule} from '@angular/common';
import {HeaderComponent} from '../header/header.component';
import {RemoveTrailingPeriodPipe} from "../util/RemoveTrailingPeriodPipe";
@Component({ @Component({
selector: 'app-about', selector: 'app-about',
standalone: false, standalone: true,
imports: [
CommonModule,
HeaderComponent,
RemoveTrailingPeriodPipe
],
templateUrl: './about.component.html', templateUrl: './about.component.html',
styleUrl: './about.component.scss' styleUrl: './about.component.scss'
}) })
export class AboutComponent { export class AboutComponent {
scrollService: any; constructor(public scrollService: ScrollService) {
}
} }
+7 -2
View File
@@ -1,13 +1,18 @@
import {Component, OnInit} from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {Meta, Title} from '@angular/platform-browser'; import {Meta, Title} from '@angular/platform-browser';
import {ALTITUDE_VERSION} from './constant'; import {ALTITUDE_VERSION} from './constant';
import {Router} from '@angular/router'; import {Router, RouterOutlet} from '@angular/router';
import {FooterComponent} from './footer/footer.component';
@Component({ @Component({
standalone: false, standalone: true,
selector: 'app-root', selector: 'app-root',
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
imports: [
FooterComponent,
RouterOutlet
]
}) })
export class AppComponent implements OnInit { export class AppComponent implements OnInit {
title = 'Survival Minecraft Server on ' + ALTITUDE_VERSION + '! | Altitude Community'; title = 'Survival Minecraft Server on ' + ALTITUDE_VERSION + '! | Altitude Community';
-56
View File
@@ -1,56 +0,0 @@
import {NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {AppComponent} from './app.component';
import {RouterModule, Routes} from '@angular/router';
import {HeaderComponent} from './header/header.component';
import {HomeComponent} from './home/home.component';
import {NgOptimizedImage} from '@angular/common';
import {ThemeComponent} from "./theme/theme.component";
import {CookieService} from 'ngx-cookie-service';
import {MapComponent} from './map/map.component';
import {RulesComponent} from './rules/rules.component';
import {CopyIpComponent} from './copy-ip/copy-ip.component';
import {FooterComponent} from './footer/footer.component';
import {TermsComponent} from './terms/terms.component';
import {PrivacyComponent} from './privacy/privacy.component';
import {TeamComponent} from './team/team.component';
import {AboutComponent} from './about/about.component';
import {SocialsComponent} from './socials/socials.component';
const routes: Routes = [
{path: '', component: HomeComponent},
{path: 'map', component: MapComponent},
{path: 'rules', component: RulesComponent},
{path: 'about', component: AboutComponent},
{path: 'socials', component: SocialsComponent},
{path: 'team', component: TeamComponent},
{path: 'terms', component: TermsComponent},
{path: 'privacy', component: PrivacyComponent},
];
@NgModule({
declarations: [
AppComponent,
HeaderComponent,
FooterComponent,
ThemeComponent,
HomeComponent,
MapComponent,
RulesComponent,
AboutComponent,
SocialsComponent,
TeamComponent,
TermsComponent,
PrivacyComponent,
],
imports: [
BrowserModule,
RouterModule.forRoot(routes),
NgOptimizedImage,
CopyIpComponent,
],
providers: [CookieService],
bootstrap: [AppComponent]
})
export class AppModule {
}
+108 -1
View File
@@ -1,3 +1,110 @@
import {Routes} from '@angular/router'; import {Routes} from '@angular/router';
export const routes: Routes = []; export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
},
{
path: 'map',
loadComponent: () => import('./map/map.component').then(m => m.MapComponent)
},
{
path: 'rules',
loadComponent: () => import('./rules/rules.component').then(m => m.RulesComponent)
},
{
path: 'vote',
loadComponent: () => import('./vote/vote.component').then(m => m.VoteComponent)
},
{
path: 'about',
loadComponent: () => import('./about/about.component').then(m => m.AboutComponent)
},
{
path: 'socials',
loadComponent: () => import('./socials/socials.component').then(m => m.SocialsComponent)
},
{
path: 'team',
loadComponent: () => import('./team/team.component').then(m => m.TeamComponent)
},
{
path: 'birthdays',
loadComponent: () => import('./birthdays/birthdays.component').then(m => m.BirthdaysComponent)
},
{
path: 'terms',
loadComponent: () => import('./terms/terms.component').then(m => m.TermsComponent)
},
{
path: 'privacy',
loadComponent: () => import('./privacy/privacy.component').then(m => m.PrivacyComponent)
},
{
path: 'bans',
loadComponent: () => import('./bans/bans.component').then(m => m.BansComponent)
},
{
path: 'bans/:type/:id',
loadComponent: () => import('./bans/details/details.component').then(m => m.DetailsComponent)
},
{
path: 'economy',
loadComponent: () => import('./economy/economy.component').then(m => m.EconomyComponent)
},
{
path: 'claiming',
loadComponent: () => import('./claiming/claiming.component').then(m => m.ClaimingComponent)
},
{
path: 'mypet',
loadComponent: () => import('./mypet/mypet.component').then(m => m.MypetComponent)
},
{
path: 'warps',
loadComponent: () => import('./warps/warps.component').then(m => m.WarpsComponent)
},
{
path: 'skyblock',
loadComponent: () => import('./skyblock/skyblock.component').then(m => m.SkyblockComponent)
},
{
path: 'customfeatures',
loadComponent: () => import('./customfeatures/customfeatures.component').then(m => m.CustomfeaturesComponent)
},
{
path: 'guide',
loadComponent: () => import('./guide/guide.component').then(m => m.GuideComponent)
},
{
path: 'ranks',
loadComponent: () => import('./ranks/ranks.component').then(m => m.RanksComponent)
},
{
path: 'commandlist',
loadComponent: () => import('./commandlist/commandlist.component').then(m => m.CommandlistComponent)
},
{
path: 'mapart',
loadComponent: () => import('./mapart/mapart.component').then(m => m.MapartComponent)
},
{
path: 'lag',
loadComponent: () => import('./lag/lag.component').then(m => m.LagComponent)
},
{
path: 'staffpowers',
loadComponent: () => import('./staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
},
{
path: 'forms/:form',
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
},
{
path: 'forms',
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
},
];
+90
View File
@@ -0,0 +1,90 @@
<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 [id]="getCurrentUserTypeButtonId('player')" class="button-outer" (click)="changeUserType('player')"
style="margin-left: 120px;">
<span class="button-inner"
[ngClass]="active">Player</span>
</div>
<div [id]="getCurrentUserTypeButtonId('staff')" class="button-outer" (click)="changeUserType('staff')">
<span class="button-inner"
[ngClass]="active">Staff</span>
</div>
</div>
<div class="historySearchContainer">
<input class="historySearch"
type="search"
placeholder="Search.."
[(ngModel)]="searchTerm"
(input)="filterNames()"
(keyup.enter)="search()"
>
<div class="dropdown-results" *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)"
(selectItem)="setSearch($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>
+171
View File
@@ -0,0 +1,171 @@
main .container {
padding: 0;
}
.container {
max-width: 1300px;
}
.columnSection {
padding: 20px 0 20px 0;
max-width: 1300px;
}
.historyButtonContainer {
width: 80%;
box-sizing: border-box;
}
.historySearchContainer {
width: 20%;
box-sizing: border-box;
text-align: right;
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
}
.button-outer {
margin-right: 15px;
background: var(--history-link-color);
}
.button-outer.active {
color: var(--navlink-color);
}
#currentButton {
text-decoration: none;
color: var(--white);
background: var(--link-color);
border-radius: 5px;
}
input.historySearch {
font-family: 'open-sans', sans-serif;
width: 260px;
border: 1px solid #DEDEDE;
padding: 9px 4px 9px 40px;
background: #DEDEDE url(/public/img/random/search.svg) no-repeat 10px;
background-size: 18px 18px;
border-radius: 5px;
}
.searchButton {
vertical-align: -15px;
}
.historyTable {
font-family: 'open-sans', sans-serif;
margin-bottom: 20px;
width: 100%;
text-align: center;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
border-radius: 5px;
z-index: 1;
}
.dropdown-results {
font-family: 'open-sans', sans-serif;
position: absolute;
top: 35px;
left: 0;
width: 100%;
max-height: 300px;
overflow-y: auto;
background-color: #DEDEDE;
border: 1px solid #ddd;
border-top: none;
z-index: 1050;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
}
.dropdown-item {
padding: 8px 12px;
cursor: pointer;
}
.dropdown-item:hover {
background-color: #f5f5f5;
}
.changePageButtons {
margin: 0 auto;
padding: 10px 0 30px 0;
}
.historyPageButton {
align-items: center;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: .25rem;
box-shadow: rgba(0, 0, 0, 0.02) 0 1px 3px 0;
box-sizing: border-box;
background-color: var(--history-page-button-background);
color: var(--font-color);
cursor: pointer;
display: inline-flex;
font-family: 'opensans', sans-serif;
font-size: 16px;
font-weight: 600;
justify-content: center;
line-height: 1.25;
margin: 0 5px 0 5px;
min-height: 3rem;
padding: calc(.875rem - 1px) calc(1.5rem - 1px);
position: relative;
text-decoration: none;
transition: all 250ms;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
vertical-align: baseline;
width: auto;
}
.historyPageButton.active:hover,
.historyPageButton.active:focus {
border-color: rgba(0, 0, 0, 0.15);
box-shadow: rgba(0, 0, 0, 0.1) 0 4px 12px;
color: var(--font-color-hover);
}
.historyPageButton.active:hover {
transform: translateY(-1px);
}
.historyPageButton.active:active {
border-color: rgba(0, 0, 0, 0.15);
box-shadow: rgba(0, 0, 0, 0.06) 0 2px 4px;
color: var(--font-color-hover);
transform: translateY(0);
}
.historyPageButton.disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--color-quaternary);
color: var(--font-color);
box-shadow: none;
pointer-events: none;
}
/* Prevent hover effects on disabled buttons */
.historyPageButton.disabled:hover,
.historyPageButton.disabled:focus {
border-color: rgba(0, 0, 0, 0.1);
box-shadow: none;
transform: none;
color: #999;
}
.pageNumber {
font-family: 'open-sans', sans-serif;
margin: 0 25px 0 25px;
color: var(--font-color);
transition: 0.5s ease;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BansComponent } from './bans.component';
describe('BansComponent', () => {
let component: BansComponent;
let fixture: ComponentFixture<BansComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BansComponent]
})
.compileComponents();
fixture = TestBed.createComponent(BansComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+221
View File
@@ -0,0 +1,221 @@
import {Component, OnInit} from '@angular/core';
import {HeaderComponent} from "../header/header.component";
import {HistoryComponent} from './history/history.component';
import {HistoryCount, HistoryService} from '../../api';
import {NgClass, NgForOf, NgIf} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {catchError, map, Observable} from 'rxjs';
import {SearchParams} from './search-terms';
@Component({
selector: 'app-bans',
imports: [
HeaderComponent,
HistoryComponent,
NgIf,
FormsModule,
NgForOf,
NgClass
],
templateUrl: './bans.component.html',
styleUrl: './bans.component.scss'
})
export class BansComponent implements OnInit {
constructor(public historyApi: HistoryService) {
}
private PAGE_SIZE: number = 10;
private uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
private actualPage: number = 0;
private totalSearchResults: number = -1;
public active: string = '';
public userType: 'player' | 'staff' = "player";
public punishmentType: 'all' | 'ban' | 'mute' | 'kick' | 'warn' = "all";
public page: number = 0;
public names: string[] = [];
public finalSearchTerm: string = '';
public searchTerm: string = '';
public filteredNames: string[] = [];
public pageSize = 10;
public historyCount: HistoryCount = {
bans: 0,
mutes: 0,
kicks: 0,
warnings: 0
}
ngOnInit() {
this.historyApi.getUserNames(this.userType, this.punishmentType).subscribe(names => {
this.names = names;
});
this.historyApi.getTotalPunishments().subscribe(totalPunishments => {
this.historyCount = totalPunishments;
})
this.pushState();
window.addEventListener('popstate', (event) => {
if (event.state && event.state.searchTerm !== undefined) {
this.searchTerm = event.state.searchTerm;
this.finalSearchTerm = event.state.searchTerm;
this.page = event.state.page;
this.userType = event.state.userType;
this.punishmentType = event.state.punishmentType;
}
});
}
public getCurrentButtonId(options: 'all' | 'ban' | 'mute' | 'kick' | 'warn') {
if (options == this.punishmentType) {
return 'currentButton'
}
return null;
}
public getCurrentUserTypeButtonId(options: 'player' | 'staff') {
if (options == this.userType) {
return 'currentButton'
}
return null;
}
public filterNames() {
if (!this.searchTerm) {
this.filteredNames = [];
return;
}
this.filteredNames = this.names.filter(name =>
name.toLowerCase().startsWith(this.searchTerm.toLowerCase())
).slice(0, 10);
}
public selectName(name: string) {
this.searchTerm = name;
this.filteredNames = [];
this.search();
}
public search() {
this.pushState()
this.finalSearchTerm = this.searchTerm;
this.page = 0;
if (this.finalSearchTerm.length === 0) {
this.totalSearchResults = -1
return
}
let totalSearchResultsObservable: Observable<number>;
if (this.uuidRegex.test(this.finalSearchTerm)) {
totalSearchResultsObservable = this.historyApi.getTotalResultsForUuidSearch(this.userType, this.punishmentType, this.finalSearchTerm);
} else {
totalSearchResultsObservable = this.historyApi.getTotalResultsForUserSearch(this.userType, this.punishmentType, this.finalSearchTerm);
}
totalSearchResultsObservable.pipe(
map(totalSearchResults => {
this.totalSearchResults = totalSearchResults;
}),
catchError(err => {
console.error(err);
return [];
})
).subscribe();
}
public changeUserType(type: 'player' | 'staff') {
this.pushState();
this.userType = type;
this.page = 0;
this.finalSearchTerm = '';
this.searchTerm = '';
this.filteredNames = [];
}
public changeHistoryPunishment(type: 'all' | 'ban' | 'mute' | 'kick' | 'warn') {
this.pushState();
this.punishmentType = type;
this.page = 0;
}
public nextPage() {
if (this.page === this.getMaxPage()) {
return;
}
this.setPage(this.page + 1)
}
public previousPage() {
if (this.page === 0) {
return;
}
this.setPage(this.page - 1)
}
public setPage(page: number) {
if (this.actualPage !== this.page) {
console.warn('Changing page too quickly, the previous page change has not loaded yet');
return;
}
this.pushState();
this.page = page
}
private pushState() {
const state = {
searchTerm: this.searchTerm,
page: this.page,
userType: this.userType,
punishmentType: this.punishmentType
};
const title = 'Altitude Bans';
const url = window.location.href;
window.history.pushState(state, title, url);
}
public getMaxPage() {
const all = this.historyCount.bans + this.historyCount.mutes + this.historyCount.warnings;
if (this.finalSearchTerm.length !== 0) {
return Math.floor(this.totalSearchResults / this.PAGE_SIZE);
}
switch (this.punishmentType) {
case 'all':
return Math.floor(all / this.PAGE_SIZE);
case 'ban':
return Math.floor(this.historyCount.bans / this.PAGE_SIZE);
case 'mute':
return Math.floor(this.historyCount.mutes / this.PAGE_SIZE);
case 'kick':
return Math.floor(this.historyCount.kicks / this.PAGE_SIZE);
case 'warn':
return Math.floor(this.historyCount.warnings / this.PAGE_SIZE);
default:
return 0;
}
}
public buttonActive(compare: number): boolean {
if (compare !== 0 && this.pageSize < this.PAGE_SIZE) {
return false;
}
return this.page !== compare;
}
public updatePageSize(pageSize: number) {
if (pageSize < 0) {
return;
} else {
this.pageSize = pageSize;
this.actualPage = this.page;
}
}
public setSearch(searchParams: SearchParams) {
this.userType = searchParams.userType
this.searchTerm = searchParams.searchTerm
this.punishmentType = searchParams.punishmentType
this.search();
}
}
@@ -0,0 +1,109 @@
<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">
<section class="columnSection">
<div class="detailsBackButton">
<ng-container *ngIf="punishment === undefined">
<p>Loading...</p>
</ng-container>
<a [routerLink]="['/bans']">< Back</a>
</div>
</section>
<section class="columnSection center">
<ng-container *ngIf="punishment">
<div>
<span class="tag tagInfo"
[ngClass]="{
'tagPermanent': this.historyFormat.isPermanent(punishment),
'tagExpired': !this.historyFormat.isPermanent(punishment)
}">
{{ this.historyFormat.getType(punishment) }}
</span>
</div>
<div>
<span
class="tag tagInfo"
[ngClass]="{
'tagActive': this.historyFormat.isActive(punishment),
'tagInactive': !this.historyFormat.isActive(punishment)
}">
{{ this.historyFormat.isActive(punishment) ? 'Active' : 'Inactive' }}
</span>
</div>
</ng-container>
</section>
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<ng-container *ngIf="punishment">
<div class="playerContainer">
<h2>Player</h2>
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(punishment.uuid, '150')"
width="150"
height="150"
alt="{{punishment.username}}'s Minecraft skin"
>
<h3 class="detailsUsername">{{ punishment.username }}</h3>
</div>
</ng-container>
</div>
</div>
<div class="columnContainer">
<div class="columnParagraph">
<ng-container *ngIf="punishment">
<div class="playerContainer">
<h2>Moderator</h2>
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(punishment.punishedByUuid, '150')"
width="150"
height="150"
alt="{{punishment.punishedBy}}'s Minecraft skin"
>
<h3 class="detailsUsername">{{ punishment.punishedBy }}</h3>
</div>
</ng-container>
</div>
</div>
<div class="columnContainer">
<div class="columnParagraph">
<ng-container *ngIf="punishment">
<div class="detailsInfo">
<h2>Reason</h2>
<p>{{ punishment.reason | removeTrailingPeriod }}</p>
</div>
</ng-container>
</div>
</div>
<div class="columnContainer">
<div class="columnParagraph">
<ng-container *ngIf="punishment">
<div class="detailsInfo">
<h2>Date</h2>
<p>{{ this.historyFormat.getPunishmentTime(punishment) }}</p>
</div>
</ng-container>
</div>
</div>
</section>
</section>
</main>
</ng-container>
<section class="columnSection">
<ng-container *ngIf="punishment">
<span>Expires</span>
<span>{{ this.historyFormat.getExpiredTime(punishment) }}</span>
<ng-container *ngIf="punishment.removedBy !== undefined && punishment.removedBy.length > 0">
<span>Un{{ this.historyFormat.getType(punishment).toLocaleLowerCase() }} reason</span>
<span>{{ punishment.removedReason == null ? 'No reason specified' : punishment.removedReason }}</span>
</ng-container>
</ng-container>
</section>
@@ -0,0 +1,68 @@
.detailsBackButton {
font-family: open-sans, sans-serif;
}
.columnSection {
padding-top: 30px;
}
h2 {
text-align: center;
}
h3 {
text-align: center;
}
.avatar {
padding: 10px 0;
}
.detailsUsername {
font-size: 1.2em;
font-family: 'opensans-bold', sans-serif;
}
.detailsInfo {
padding-top: 50px;
}
.detailsInfo p {
text-align: center;
}
.tag {
display: inline-block;
padding: 5px 10px;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 0.25rem;
font-family: 'opensans-bold', sans-serif;
}
.tagInfo {
margin: 0 20px;
font-size: 1.1em;
}
.tagActive {
color: #FFFFFF;
background-color: #EE5555;
}
.tagInactive {
color: #FFFFFF;
background-color: #F79720;
}
.tagPermanent {
color: #FFFFFF;
background-color: #EE5555;
}
.tagExpired {
color: #FFFFFF;
background-color: #777777
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DetailsComponent } from './details.component';
describe('DetailsComponent', () => {
let component: DetailsComponent;
let fixture: ComponentFixture<DetailsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DetailsComponent]
})
.compileComponents();
fixture = TestBed.createComponent(DetailsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,71 @@
import {Component, OnInit} from '@angular/core';
import {HistoryService, PunishmentHistory} from '../../../api';
import {NgClass, NgIf, NgOptimizedImage} from '@angular/common';
import {RemoveTrailingPeriodPipe} from '../../util/RemoveTrailingPeriodPipe';
import {HistoryFormatService} from '../history-format.service';
import {ActivatedRoute, RouterLink} from '@angular/router';
import {catchError, map} from 'rxjs';
import {HeaderComponent} from '../../header/header.component';
@Component({
selector: 'app-details',
imports: [
NgIf,
NgOptimizedImage,
RemoveTrailingPeriodPipe,
HeaderComponent,
RouterLink,
NgClass
],
templateUrl: './details.component.html',
styleUrl: './details.component.scss'
})
export class DetailsComponent implements OnInit {
type: 'all' | 'ban' | 'mute' | 'kick' | 'warn' = 'all';
id: number = -1;
punishment: PunishmentHistory | undefined;
constructor(private route: ActivatedRoute,
private historyApi: HistoryService,
public historyFormat: HistoryFormatService) {
}
ngOnInit(): void {
this.route.paramMap.subscribe(params => {
switch (params.get('type')) {
case 'ban':
this.type = 'ban';
break;
case 'mute':
this.type = 'mute';
break;
case 'kick':
this.type = 'kick';
break;
case 'warn':
this.type = 'warn';
break;
default:
throw new Error("Invalid type");
}
this.id = Number(params.get('id') || '-1');
this.loadPunishmentData();
});
}
private loadPunishmentData() {
if (!this.type || this.type === 'all' || this.id < 0 || isNaN(this.id)) {
console.error("Invalid type or id");
return;
}
this.historyApi.getHistoryById(this.type, this.id).pipe(
map(punishment => {
this.punishment = punishment;
}),
catchError(err => {
console.error(err);
return [];
})
).subscribe();
}
}
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { HistoryFormatService } from './history-format.service';
describe('HistoryFormatService', () => {
let service: HistoryFormatService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(HistoryFormatService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
@@ -0,0 +1,68 @@
import {Injectable} from '@angular/core';
import {PunishmentHistory} from '../../api';
@Injectable({
providedIn: 'root'
})
export class HistoryFormatService {
constructor() {
}
public getPunishmentTime(entry: PunishmentHistory) {
const date = new Date(entry.punishmentTime);
return date.toLocaleString(navigator.language, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
public isActive(entry: PunishmentHistory): boolean {
if (entry.removedBy !== null) {
return false;
}
if (entry.expiryTime <= 0) {
return true;
}
return entry.expiryTime > Date.now();
}
public isPermanent(entry: PunishmentHistory): boolean {
return entry.expiryTime <= 0;
}
public getType(entry: PunishmentHistory): string {
return entry.type.charAt(0).toUpperCase() + entry.type.slice(1);
}
public getExpiredTime(entry: PunishmentHistory): string {
let suffix: string = '';
if (entry.removedBy !== null && entry.removedBy !== undefined) {
suffix = '(Unbanned by ' + entry.removedBy + ')';
}
if (entry.expiryTime <= 0) {
return "Permanent " + this.getType(entry) + " " + suffix;
}
const date = new Date(entry.expiryTime);
return date.toLocaleString(navigator.language, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
}) + " " + suffix;
}
public getAvatarUrl(entry: string, size: string = '25'): string {
let uuid = entry.replace('-', '');
if (uuid === 'C') {
uuid = "f78a4d8dd51b4b3998a3230f2de0c670"
}
return `https://crafatar.com/avatars/${uuid}?size=${size}&overlay`;
}
}
@@ -0,0 +1,53 @@
<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 class="historyPlayerRow" *ngFor="let entry of history">
<td class="historyType" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getType(entry) }}
</td>
<td class="historyPlayer" (click)="setSearch(entry.username, 'player')">
<div class="playerContainer">
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.uuid)" width="25" height="25"
alt="{{entry.username}}'s Minecraft skin">
<span class="username">{{ entry.username }}</span>
</div>
</td>
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
<div class="playerContainer">
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25" height="25"
alt="{{entry.punishedBy}}'s Minecraft skin">
<span>{{ entry.punishedBy }}</span>
</div>
</td>
<td class="historyReason" (click)="showDetailedPunishment(entry)">
{{ entry.reason | removeTrailingPeriod }}
</td>
<td class="historyDate" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getPunishmentTime(entry) }}
</td>
<td class="historyDate" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getExpiredTime(entry) }}
</td>
</tr>
</tbody>
</div>
</table>
</ng-container>
@@ -0,0 +1,113 @@
table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
border-radius: 5px;
}
thead {
height: 25px;
font-size: 1.1em;
}
tbody {
font-size: 0.9em;
}
td {
padding: 5px 0 5px 0;
height: 55px;
}
th {
padding: 5px 0 5px 0;
}
th, td {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding: 5px 0 8px 0;
box-sizing: border-box;
}
th.historyType, td.historyType {
width: 100px !important;
min-width: 100px;
max-width: 100px;
white-space: nowrap;
}
th.historyPlayer, td.historyPlayer {
width: 180px !important;
min-width: 180px;
max-width: 180px;
}
th.historyReason, td.historyReason {
width: 540px !important;
min-width: 540px;
max-width: 540px;
}
th.historyDate, td.historyDate {
width: 150px !important;
min-width: 150px;
max-width: 150px;
}
table tr td {
vertical-align: middle;
}
.historyPlayerRow:hover {
background-color: var(--history-table-row-color);
cursor: pointer;
}
.historyTableHead {
width: 1300px;
background-color: var(--history-table-head-color);
transition: all 100ms cubic-bezier(0.55, 0.085, 0.68, 0.53), 0.5s background ease;
border-radius: 5px;
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.2), 0 6px 20px -8px rgba(0, 0, 0, 0.19);
}
.historyType {
max-width: 100%;
width: 80px;
}
.historyPlayer {
max-width: 100%;
width: 200px;
text-overflow: clip;
}
.playerContainer {
display: inline-flex;
flex-direction: column;
align-items: center;
}
.username {
text-align: center;
}
img {
vertical-align: middle;
border-style: none;
}
.avatar {
margin-bottom: 5px;
border-radius: 2px;
}
.historyReason {
width: 650px;
}
.historyDate {
width: 170px;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HistoryComponent } from './history.component';
describe('HistoryComponent', () => {
let component: HistoryComponent;
let fixture: ComponentFixture<HistoryComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HistoryComponent]
})
.compileComponents();
fixture = TestBed.createComponent(HistoryComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,108 @@
import {Component, EventEmitter, Input, OnChanges, OnInit, Output} from '@angular/core';
import {BASE_PATH, HistoryService, PunishmentHistory} from '../../../api';
import {catchError, map, Observable, shareReplay} from 'rxjs';
import {NgForOf, NgIf, NgOptimizedImage} from '@angular/common';
import {CookieService} from 'ngx-cookie-service';
import {RemoveTrailingPeriodPipe} from '../../util/RemoveTrailingPeriodPipe';
import {HttpErrorResponse} from '@angular/common/http';
import {environment} from '../../../environments/environment';
import {HistoryFormatService} from '../history-format.service';
import {SearchParams} from '../search-terms';
import {Router} from '@angular/router';
@Component({
selector: 'app-history',
imports: [
NgIf,
NgForOf,
NgOptimizedImage,
RemoveTrailingPeriodPipe
],
templateUrl: './history.component.html',
styleUrl: './history.component.scss',
providers: [
CookieService,
{provide: BASE_PATH, useValue: environment.apiUrl}
],
})
export class HistoryComponent implements OnInit, OnChanges {
@Input() userType: 'player' | 'staff' = "player";
@Input() punishmentType: 'all' | 'ban' | 'mute' | 'kick' | 'warn' = "all";
@Input() page: number = 0;
@Input() searchTerm: string = '';
@Output() pageChange = new EventEmitter<number>();
@Output() selectItem = new EventEmitter<SearchParams>();
private uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
public history: PunishmentHistory[] = []
constructor(private historyApi: HistoryService, public historyFormat: HistoryFormatService, private router: Router) {
}
ngOnChanges(): void {
this.reloadHistory();
}
ngOnInit(): void {
this.reloadHistory();
}
private reloadHistory(): void {
let historyObservable: Observable<PunishmentHistory[]>;
if (this.searchTerm.length === 0) {
historyObservable = this.historyApi.getHistoryForAll(this.userType, this.punishmentType, this.page);
} else {
if (this.uuidRegex.test(this.searchTerm)) {
historyObservable = this.historyApi.getHistoryForUuid(this.userType, this.punishmentType, this.searchTerm, this.page);
} else {
historyObservable = this.historyApi.getHistoryForUsers(this.userType, this.punishmentType, this.searchTerm, this.page);
}
}
historyObservable.pipe(
map(history => {
this.history = history;
this.pageChange.emit(this.history.length);
}),
catchError(err => {
this.pageChange.emit(-1);
let retrySeconds = 5;
if (err instanceof HttpErrorResponse) {
if (err.status !== 429) {
return this.history;
}
const headers = err.headers;
const retryAfterHeader = headers.get('Retry-After');
console.warn(err.error);
if (retryAfterHeader) {
const retryAfter = parseInt(retryAfterHeader || '0', 10);
if (!isNaN(retryAfter) && retryAfter > 0) {
retrySeconds = retryAfter + 1;
}
}
} else {
console.error(err);
}
setTimeout(() => this.reloadHistory(), retrySeconds * 1000);
return this.history;
}),
shareReplay(1)
).subscribe();
}
setSearch(name: string, userType: 'player' | 'staff') {
let searchParams: SearchParams = {
userType: userType,
punishmentType: 'all',
searchTerm: name
}
this.selectItem.emit(searchParams);
}
public showDetailedPunishment(entry: PunishmentHistory) {
this.router.navigate([`bans/${entry.type}/${entry.id}`]).then();
}
}
+5
View File
@@ -0,0 +1,5 @@
export interface SearchParams {
userType: 'player' | 'staff';
searchTerm: string;
punishmentType: 'all' | 'ban' | 'mute' | 'kick' | 'warn';
}
@@ -0,0 +1,16 @@
<ng-container>
<app-header [current_page]="'birthdays'" height="460px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Famous Altitude Staff Birthdays</h1>
<h2>(and previous staff)</h2>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
</div>
</section>
</main>
</ng-container>
@@ -0,0 +1,14 @@
.birthdayMember {
width: 33%;
min-width: 250px;
padding-bottom: 50px;
text-align: center;
}
.birthdayMember img {
padding-bottom: 15px;
}
.birthdayMember p {
font-family: 'opensans-bold', sans-serif;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BirthdaysComponent } from './birthdays.component';
describe('BirthdaysComponent', () => {
let component: BirthdaysComponent;
let fixture: ComponentFixture<BirthdaysComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BirthdaysComponent]
})
.compileComponents();
fixture = TestBed.createComponent(BirthdaysComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,19 @@
import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service';
import {CommonModule} from '@angular/common';
import {HeaderComponent} from '../header/header.component';
@Component({
selector: 'app-birthdays',
standalone: true,
imports: [
CommonModule,
HeaderComponent
],
templateUrl: './birthdays.component.html',
styleUrl: './birthdays.component.scss'
})
export class BirthdaysComponent {
constructor(public scrollService: ScrollService) {
}
}
@@ -0,0 +1,176 @@
<ng-container>
<app-header [current_page]="'claiming'" height="460px" background_image="/public/img/backgrounds/trees.jpg"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Claiming</h1>
<h2>Claiming allows you to protect your items and builds from other players.</h2>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<h2>Getting Started</h2>
<p>You can “claim” land on Altitude to protect your builds. When you claim a section of land, other players
will be unable to place or break blocks, kill animals, take crops, or open chests. Everything will be
completely protected!</p>
<p>To claim land, you will need some basic tools: a golden shovel, and a stick. You can craft these items
yourself or do <b>/claim</b> to receive them for free. The shovel is used for modifying claims and the
stick is used for viewing claim information.</p>
<img ngSrc="/public/img/items/shovel.png" alt="Minecraft golden shovel" style="width: 25%;" height="114"
width="114">
<p>Players start with an allowance of 500 “claim blocks” and can purchase additional “claim blocks” with
in-game currency using <span style="font-family: 'opensans-bold', sans-serif;">/buyclaimblocks</span>. How
many claims you can create depends on the rank you have - the amount can be found on the <a
[routerLink]="['/ranks']">ranks</a> page. Additionally, there is also a limit on how many blocks in
total you can
claim.</p>
</div>
<div class="columnParagraph">
<h2>Creating a Claim</h2>
<p>Before creating any claim, we should check if there are other claims by other players nearby. Claims by
different people should be at least 100 blocks apart unless both parties discuss otherwise. To do this,
hold a stick in your hand and <b>shift + right click</b> to visualize all nearby claims. Claims are
visualized using dotted lines of gold blocks, so look around for any dotted lines on the ground around
you.</p>
<p>Once you've confirmed that there are no claims too close, we can begin claiming! A claim doesn't go down
all the way. The lowest point of the claim starts at 5 blocks below the lower corner you made. It'll keep
expanding down as you place blocks lower and lower in the claim, but it may reset when you change the
claim size, trust, subclaims etc. If you want to make sure your claim stays extended all the way down,
make one of the initial corners at bedrock level. Remember to not claim further down than you need to.</p>
<p>If a claim is within 100 blocks you will be unable to create your claim. If the original claim owner is
fine with you having a claim closer than the 100 blocks, then they can trust you using <span
style="font-family: 'opensans-bold', sans-serif;">/claimnearbytrust</span>. You will need to be trusted
in order to claim and resize. The trust expires on every server reboot, this is to remind you that there
are nearby claims.</p>
</div>
<div class="columnParagraph">
<h2>Automatic Claim Deletion</h2>
<p>When a player has been inactive for 60 consecutive days, on a server (not cross-server), their claim(s)
on that server will be transferred into an admin claim(s). The claim will be in that state for 24 hours
after which is will be fully unclaimed. During the time the claim is an admin claim everyone will be
trusted. This means that you will be able to raid the claim, but not claim it. If you wish to claim the
build you will have to return 24 hours later.</p>
<p>If you are planning on being gone this long, all you need to do is join for a couple of minutes within
those 60 days to reset the timer. Unclaimed land is fair game to loot, so make sure your claims are active
and properly sized!</p>
<p>Any containers (i.e shulker, chest) that are placed within an active raid-claim are fair game - this also
includes containers that raiders place. This means that items a raider puts into their personal shulker
aren't safe while placed down or when on the ground as an item after being broken, as anyone that comes by
can take it.</p>
</div>
<div class="columnParagraph">
<h2>Useful Commands</h2>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/buyclaimblocks -</span> Buy more claim
blocks
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/sellclaimblocks -</span> Sell claim blocks
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/unclaim -</span> Remove a protection</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/claimslist -</span> List all of your
protections
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/expandclaim &lt;number&gt; -</span> Expand
the claim the direction you're facing (can also be used with negative numbers to downsize the claim)
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/containertrust &lt;player&gt; -</span> Give
someone access to chests in your claim
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/permissiontrust &lt;player&gt; -</span> Give
someone the ability to trust and untrust others in your claim
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/accesstrust &lt;player&gt; -</span> Give
someone access to doors and trapdoors in your claim
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/subdivide -</span> Create claims within your
main claim that you can trust people to
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/restrictsubclaim -</span> Sets subclaim NOT
to inherit perms from the parent claim
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/trust &lt;player&gt; -</span> Allow someone
to build and use chests within your claim
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/trustlist -</span> See who is trusted in your
claim
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/untrust &lt;player&gt; -</span> Remove a
player from your protection
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/kickfromclaim &lt;player&gt; -</span> Kick a
player outside of your claim
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petkick -</span> Enter pet kicking mode
(right click pets you don't want in your claim)
</li>
</ul>
</div>
</div>
<div class="columnContainer">
<div class="columnParagraph">
<h2>Resizing Your Claim</h2>
<p>You can make your claim bigger or smaller any time by using a golden shovel. The process is similar to
creating the claim. To make things easier, start by right clicking within the claim with a stick. This
will show the border of the claim so you can see where the corners are.</p>
<p>To resize a claim, right click a corner you would like to move. Then, right click again wherever you
would like to move that corner to. To make things easier, you don't need to right click the actual block,
you can just point your crosshair at the block and right click from a distance.</p>
<p>If you don't have the needed amount of claim blocks to expand (or create) your claim, you will need to
purchase additional claim blocks. The price for purchasing claim blocks is different depending on how many
you have:</p>
<ul>
<li>0-10,000 blocks = $0.50/block</li>
<li>10,000-50,000 blocks = $0.75/block</li>
<li>50,000-300,000 blocks = $1.00/block</li>
<li>300,000-1,000,000 blocks = $2.00/block</li>
<li>1,000,000+ blocks = $5.00/block</li>
</ul>
<p>If you have left over claim blocks you can sell them using <span
style="font-family: 'opensans-bold', sans-serif;">/sellclaimblocks</span>. The return will always be
$0.50/block. This means that you need to be mindful of purchasing large amounts of claim blocks; don't
purchase them unless you actually need them.</p>
<img ngSrc="/public/img/backgrounds/claim.png" alt="Screenshot showing claim border in-game"
style="width: 100%;"
width="480" height="250">
</div>
<div class="columnParagraph">
<h2>Subdividing Claims</h2>
<p>Subdividing claims allows you to give players access to specific parts of your claim. This is the best
way to manage a town or shop!</p>
<p>The idea behind subdivisions is easy. You are creating mini-claims within your main claim so that you can
trust people to certain areas, but not the entire claim. You are still in control of the border of these
mini-claims (size and location). Once a subdivision is created you can trust people the same way as
before. Just stand in the mini-claim and use the trust commands. It will only trust them to the
mini-claim. (To trust someone to your main claim you will need to stand outside of any mini-claims you've
created.)</p>
<p>To create a subdivision you need to go into "subdivision mode". You can access this mode with <b>/subdivideclaims</b>.
Once you are in this mode you can create the subdivisions the same way you would make a regular claim,
just right click two corners. Subdivisions will be outlined with a white dotted line. To leave subdivision
mode use <b>/basicclaims</b>.</p>
<img ngSrc="/public/img/backgrounds/townsubdivisions.png" alt="Screenshot of town subdivided"
style="width: 100%;"
width="480" height="250">
</div>
<div class="columnParagraph">
<h2>Trusting Others</h2>
<p>You can add other players to your protected area with various levels of access. To trust everyone on the
server replace &lt;player&gt; with "all". This works with all of the commands below.</p>
<ul>
<li>To give someone full access to your claim (break, build, access chests, etc) do: <b>/trust &lt;player&gt;</b>
</li>
<li>To give someone just the ability to access storage items, but NOT build, do: <b>/containertrust &lt;player&gt;</b>
</li>
<li>To give someone visitor access (opening the doors, but not take anything or build anything) do: <b>/accesstrust
&lt;player&gt;</b></li>
<li>To give someone permission to manage your claim (trusting other players) do: <b>/permissiontrust &lt;player&gt;</b>
</li>
</ul>
</div>
</div>
</section>
</section>
</main>
</ng-container>
@@ -0,0 +1,9 @@
main ul {
font-family: opensans, sans-serif;
text-align: left;
}
main li {
margin-left: 30px;
padding-bottom: 10px;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ClaimingComponent } from './claiming.component';
describe('ClaimingComponent', () => {
let component: ClaimingComponent;
let fixture: ComponentFixture<ClaimingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ClaimingComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ClaimingComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,18 @@
import {Component} from '@angular/core';
import {HeaderComponent} from '../header/header.component';
import {NgOptimizedImage} from '@angular/common';
import {RouterLink} from '@angular/router';
@Component({
selector: 'app-claiming',
imports: [
HeaderComponent,
NgOptimizedImage,
RouterLink
],
templateUrl: './claiming.component.html',
styleUrl: './claiming.component.scss'
})
export class ClaimingComponent {
}
@@ -0,0 +1,496 @@
<ng-container>
<app-header [current_page]="'commandlist'" height="460px" background_image="/public/img/backgrounds/trees.jpg"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Command list</h1>
<h2>Looking for a specific command, or the description of one? This is where you find it.</h2>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<p style="font-family: 'opensans-bold', sans-serif; padding-bottom: 5px !important;">Claiming</p>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/claim -</span> Gives you the claim kit</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/buyclaimblocks &lt;amount&gt; -</span> Allows
you to buy claimblocks (0.5 in game per block)
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/sellclaimblocks &lt;amount&gt; -</span>
Allows you to sell claimblocks (0.5 in game per block)
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/abandonallclaims -</span> Deletes all your
claims
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/abandonclaim -</span> Deletes the
claim/subdivision you're currently in
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/delclaim -</span> Deletes the
claim/subdivision you're currently in
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/unclaim -</span> Deletes the
claim/subdivision you're currently in
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/claimlist -</span> Shows you all your claims
and claimblocks
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/accesstrust &lt;username/public&gt; -</span>
Gives accesstrust to a user/everyone
</li>
<li><span
style="font-family: 'opensans-bold', sans-serif;">/containertrust &lt;username/public&gt; -</span> Gives
containertrust to a user/everyone
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/expandclaim &lt;amount&gt; -</span> Expands
claim by the specified amount in the direction you're facing
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/restrictsubclaim -</span> Seperates
permissions for the subclaim you are standing in from the main claim
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/subdivide -</span> Turns on subdivide mode
(hold golden shovel) to allow you to create subdivisions
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/trustlist -</span> Shows users trusted in the
claim/subclaim you're standing it
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/claimnearbytrust -</span> Allows other
players to claim within 100 blocks to your claim
</li>
</ul>
<p style="font-family: 'opensans-bold', sans-serif; padding: 25px 0 5px;">Creative</p>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot help -</span> Display all creative
commands
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot info -</span> Display plot info</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot confirm -</span> Confirm an action</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot claim -</span> Claim the current plot
you're standing on
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot auto -</span> Claim a random available
plot nearby
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot delete -</span> Delete your plot</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot trust -</span> Allow a player to build
in your plot
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot add -</span> Allow a user to build while
you are online
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot setowner -</span> Set the plot owner
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot setbiome -</span> Set the plot biome
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot visit -</span> Visit someone (or your
own) plot
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot kick -</span> Kick a player from your
plot
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot middle -</span> Teleports you to the
center of the current plot
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot untrust -</span> Remove a player from
your plot
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot merge [auto] -</span> Merge a plot (or
multiple)
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot unmerge -</span> Unmerge plots</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot sethome -</span> Set the plot home</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/plot clear -</span> Clear a plot</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/nightvision -</span> Enables nightvision</li>
</ul>
<p style="font-family: 'opensans-bold', sans-serif; padding: 25px 0 5px;">Dynamic Map</p>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/dynmap -</span> Sends you a link to the
Altitude map page
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/dynmap hide -</span> Hides you on the map
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/dynmap show -</span> Shows you on the map
</li>
</ul>
<p style="font-family: 'opensans-bold', sans-serif; padding: 25px 0 5px;">McMMO</p>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/mctop &lt;ability&gt; -</span> Shows the top
10 players for Power Level or the specified ability
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/mcrank -</span> Shows where you are on the
mctop per skill
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/mcstats -</span> Shows your mcmmo levels in a
scoreboard
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/mcability -</span> Toggles ability use on or
off
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/acrobatics [?] [page] -</span> Shows your
stats for Acrobatics. If you add ? it will show the guide for Acrobatics
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/archery [?] [page] -</span> Shows your stats
for Archery. If you add ? it will show the guide for Archery
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/axes [?] [page] -</span> Shows your stats for
Axes. If you add ? it will show the guide for Axes
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/excavation [?] [page] -</span> Shows your
stats for Excavation. If you add ? it will show the guide for Excavation
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/fishing [?] [page] -</span> Shows your stats
for Fishing. If you add ? it will show the guide for Fishing
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/herbalism [?] [page] -</span> Shows your
stats for Herbalism. If you add ? it will show the guide for Herbalism
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/mining [?] [page] -</span> Shows your stats
for Mining. If you add ? it will show the guide for Mining
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/repair [?] [page] -</span> Shows your stats
for Repair. If you add ? it will show the guide for Repair
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/swords [?] [page] -</span> Shows your stats
for Swords. If you add ? it will show the guide for Swords
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/taming [?] [page] -</span> Shows your stats
for Taming. If you add ? it will show the guide for Taming
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/unarmed [?] [page] -</span> Shows your stats
for Unarmed. If you add ? it will show the guide for Unarmed
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/woodcutting [?] [page] -</span> Shows your
stats for Woodcutting. If you add ? it will show the guide for Woodcutting
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/inspect &lt;username&gt; -</span> Shows mcmmo
levels for specified user in a scoreboard if they are near you
</li>
</ul>
<p style="font-family: 'opensans-bold', sans-serif; padding: 25px 0 5px;">MyPet</p>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petinfo -</span> Shows information about your
MyPet
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petname &lt;name&gt; -</span> Gives your
current MyPet the specified name
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petrelease &lt;name&gt; -</span> Release the
specified MyPet (this will despawn it)
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petcall (/petc) -</span> Teleports your
current MyPet to you
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/psa -</span> Sends your MyPet away, it can
still be called using /petc (does not store your pet)
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petswitch (/psw) -</span> Switches between
current and stored MyPet
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petstore (/pst) -</span> Stores your MyPet
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/pettrade &lt;username&gt; [price] -</span>
Trades current MyPet with a player
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petshop -</span> Opens the MyPet shop</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petskill -</span> Shows information about the
skills of your MyPet
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petstop -</span> Stops your MyPet from
attacking a target if it's not in farm or aggressive behavior modes
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petchooseskilltree (/pcst) -</span> Allows
you to select a skilltree for your MyPet
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petcapturehelper (/pch) -</span>
Enables/disables the CaptureHelper for MyPet
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petoptions healthbar -</span> Toggles
actionbar healthbar on/off
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petoptions idle-volume &lt;%&gt; -</span>
Sets volume percentage for the MyPet
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petinventory (/peti) -</span> Opens the
inventory for your current MyPet if it has one
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petpickup (/pp) -</span> Toggles pet pickup
on/off if your pet has an inventory
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petbehavior [mode] (/pb) -</span> Switches
between pet behaviors or to the specified one
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/petbeacon -</span> Opens the beacon editor
for your MyPet if it has one
</li>
</ul>
</div>
</div>
<div class="columnContainer">
<div class="columnParagraph">
<p style="font-family: 'opensans-bold', sans-serif; padding-bottom: 5px !important;">Economy</p>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/bal -</span> Shows you your balance</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/cheque &lt;amount&gt; -</span> Creates a
cheque with the specified amount of money if you're holding a piece of paper
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/pay &lt;username&gt; &lt;amount&gt; [-confirmed] -</span>
Pays the specified amount to the specified user
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/shop -</span> Opens the shop GUI</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/points -</span> Displays your current amount
of sell/buy points
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/sell &lt;item&gt; -</span> Tells you if the
entered item is sellable at spawn, and at what price
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/buy &lt;item&gt; -</span> Tells you if the
entered item is buyable at spawn, and at what price
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/playershop checkstock &lt;radius&gt; [max. stock] -</span>
highlights all of your shops which do not meet the minimum amount specified
</li>
</ul>
<p style="font-family: 'opensans-bold', sans-serif; padding: 25px 0 5px;">Party Chat</p>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/party help -</span> Display the help menu
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/party create &lt;partyname&gt; &lt;password&gt; -</span>
Creates a party
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/party info -</span> Displays information
about your current party
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/party invite &lt;username&gt; -</span> Invite
a user to your party
</li>
<li><span
style="font-family: 'opensans-bold', sans-serif;">/party join &lt;partyname&gt; [password] -</span> Join
a users party (password is required only if the party has one)
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/p &lt;message&gt; -</span> Changes chat to
party chat. If a message is specified it is sent to party chat
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/party leave -</span> Leave a party</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/party name &lt;newname&gt; -</span> Leave a
party
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/party owner &lt;username&gt; -</span> Makes
the specified user owner of the party
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/party password &lt;password&gt; -</span> Sets
or changes a password for the party
</li>
<li><span
style="font-family: 'opensans-bold', sans-serif;">/party remove (/party kick) &lt;username&gt; -</span>
Kicks the specified user from the party
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/party disband -</span> Disband your party
</li>
</ul>
<p style="font-family: 'opensans-bold', sans-serif; padding: 25px 0 5px;">Voting</p>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/vote -</span> Sends a link to our vote page
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/votebest -</span> Shows highest total votes
you reached per day/week/month
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/votelast -</span> Shows when you last voted
on each of the websites
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/votenext -</span> Shows when you can vote
again per website
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/votestreak -</span> Shows your current and
best vote streak
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/votetop [page] -</span> Shows the vote top
page
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/votetotal -</span> Shows you your total votes
</li>
</ul>
<p style="font-family: 'opensans-bold', sans-serif; padding: 25px 0 5px;">Warps</p>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/warps [warp] -</span> Opens the warp GUI or
teleports you to the specified warp
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/warp apply -</span> Starts application
process for a new warp (costs 25k)
</li>
</ul>
<p style="font-family: 'opensans-bold', sans-serif; padding: 25px 0 5px;">Altitude</p>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/queue -</span> Shows what server you are
queued for
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/find &lt;username&gt; -</span> Shows if and
where this user is online
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/back -</span> Teleports you back to where you
last teleported from
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/dback -</span> Brings you back to the last
location you died at
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/colors -</span> Shows all color and format
codes
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/home [name] -</span> Shows your homes (click
to teleport), if a home is specified it will teleport you there
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/list -</span> Lists all online players on the
server
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/stafflist -</span> Lists all online staff
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/seen &lt;username&gt; -</span> Shows when a
player is/was online
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/playtime -</span> Shows your playtime</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/sit -</span> Allows you to sit down</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/cooldown -</span> Shows the cooldown for the
rtp portal
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/hat -</span> Puts the current item on your
head
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/discord -</span> Sends you a link to the
Altitude Discord server
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/ranks -</span> Shows the ranks on the
Altitude server
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/contact -</span> Sends a link to our Contact
page
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/report -</span> Sends a link to the report
form
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/rules -</span> Sends a link to the rules page
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/store -</span> Sends a link to the Altitude
store
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/guide -</span> Sends a link to the guide page
for getting started
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/info -</span> Sends some links to our website
and Discord
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/se &lt;signline&gt; &lt;text&gt; -</span>
While looking at a sign changes text on specified line to specified text.
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/editsign -</span> Toggles shift click sign
editing
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/kickfromclaim -</span> Kicks specified user
from claim
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/kickpet -</span> Toggles kickpet mode, kicks
vanilla pets from claims after right clicking it
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/warnings -</span> Shows all active warnings
for yourself
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/realname &lt;username/nickname&gt; -</span>
Shows the specified users username and nickname
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/mapart remove -</span> Deletes map from the
database if you own it and are holding it
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/mapart save -</span> Saves map on the
database if you're holding it
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/sneakclickmending -</span> Allows you to mend
your items by right clicking while sneaking
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/pu rotateblock -</span> Rotate already
placed, direction-specific, blocks
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/pu xpcalc &lt;current xp&gt; &lt;goal xp&gt; -</span>
Calculates the amount of XP needed to get from level X to level Y
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/pu xpcheque &lt;xp&gt; -</span> Creates a
'cheque' in the form of an XP bottle with the specified amount of XP
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/pt top [daily/weekly/monthly/total]-</span>
Displays the players with the highest amount of playtime for the specified time
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/finditem &lt;item&gt; -</span> Enables you to
search your chest monster for a specific item
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/local -</span> Toggles local chat, where
messages will only be sent to players within a 200 block distance
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/blockitempickup -</span> Prevents picking up
items
</li>
</ul>
<p style="font-family: 'opensans-bold', sans-serif; padding: 25px 0 5px;">Miscellaneous</p>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/apart -</span> Opens particles GUI</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/cmi armorstand [last/near] -</span> Opens
Armorstand editor
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/ping -</span> Shows your ping</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/time -</span> Shows in-game time</li>
<li><span
style="font-family: 'opensans-bold', sans-serif;">/mail send &lt;username&gt; &lt;message&gt; -</span>
Sends a mail to the specified user
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/ignore &lt;username&gt; -</span> Ignores a
users messages to you and hides their public chat messages from you
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/r &lt;message&gt; -</span> Replies to the
last user to send you a message
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/c &lt;message&gt; -</span> Replies to the
last user you sent a message to
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/msg &lt;username&gt; &lt;message&gt; -</span>
Sends a message to the specified user
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/pvp [on/off] -</span> Toggles your pvp on/off
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/sethome &lt;homename&gt; -</span> Sets a home
with the specified name
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/delhome &lt;homename&gt; -</span> Shows your
homes (click to remove), or removes the specified home
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/spawn -</span> Teleports you to spawn</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/tpa &lt;username&gt; -</span> Sends a
teleport request to the specified user
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/tpaaccept [username] -</span> Accepts last
teleport request, or the teleport request of the specified user
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/tpahere &lt;username&gt; -</span> Sends a
teleport here request to the specified user
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/tpbypass -</span> Used to teleport to a
location the plugin marks as unsafe (such as void or lava)
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/tpdeny [username] -</span> Denies last
teleport request, or the teleport request of the specified user
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/givepet &lt;username&gt; -</span> Gives
vanilla pet to specified user after you right click it
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/trapped -</span> Teleports you out of the
claim you are in if you do not have build permission there
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/colorsextra -</span> Displays useable RGB
colors (shift+lmb to paste in chat)
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/dispose (/trash) -</span> Opens a GUI where
you can dispose of items
</li>
</ul>
</div>
</div>
</section>
</section>
</main>
</ng-container>
@@ -0,0 +1,14 @@
main ul {
font-family: 'opensans', sans-serif;
text-align: left;
}
main li {
margin-left: 30px;
padding-bottom: 10px;
}
li {
color: var(--font-color);
transition: 0.5s ease;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CommandlistComponent } from './commandlist.component';
describe('CommandlistComponent', () => {
let component: CommandlistComponent;
let fixture: ComponentFixture<CommandlistComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CommandlistComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CommandlistComponent);
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-commandlist',
imports: [
HeaderComponent
],
templateUrl: './commandlist.component.html',
styleUrl: './commandlist.component.scss'
})
export class CommandlistComponent {
}
@@ -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 {
}
@@ -1,8 +1,13 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {CommonModule} from '@angular/common';
@Component({ @Component({
selector: 'app-copy-ip', selector: 'app-copy-ip',
templateUrl: './copy-ip.component.html', templateUrl: './copy-ip.component.html',
standalone: true,
imports: [
CommonModule
],
styleUrl: './copy-ip.component.scss' styleUrl: './copy-ip.component.scss'
}) })
export class CopyIpComponent { export class CopyIpComponent {
@@ -0,0 +1,179 @@
<ng-container>
<app-header [current_page]="'skyblock'" height="460px" background_image="/public/img/backgrounds/path.jpg"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Custom Features</h1>
<h2>A list of custom features created specifically to enhance the survival experience on Altitude.</h2>
<h3
style="font-family: 'minecraft-text', sans-serif; font-size: 0.8rem; margin-top: 10px; color: #FFFFFF !important;">
Commands for the features are listed <a [routerLink]="['/commandlist']">here</a></h3>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<h2>Blocks</h2>
<p style="padding-bottom: 0 !important;">Beacons can be activated by redstone</p>
<ul class="full-page-list">
<li>Powering the block above a beacon disables the beam</li>
</ul>
<p>Glow lichen can be silk touched</p>
<p>Vines can be silk touched and bonemealed</p>
<p>Coral and coral fans can be bonemealed to grow a new coral tree</p>
<p>Mushroom blocks can be stripped with an axe</p>
<p>Candles can be placed without a block beneath them</p>
<p>Crying obsidian can be used to create nether portals</p>
<p style="padding-bottom: 0 !important;">Gravel and sand are craftable in stonecutters</p>
<ul class="full-page-list">
<li>Cobblestone -> Gravel</li>
<li>Gravel -> Sand</li>
</ul>
<p>Sponges can remove lava - wet sponges remove more (Note: the sponge will be consumed)</p>
<p>Item frames can be made invisible using a diamond</p>
<p>Rotation of item frames can be locked using a slime ball</p>
<p>Copper can be oxidized using a cauldron of water</p>
<p>Pink petals can be placed on any surface</p>
<p>Wither roses can be bonemealed</p>
</div>
</div>
</section>
</section>
<section class="darkmodeSectionThree">
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<h2>Containers</h2>
<p>Dispensers can interact with campfires</p>
<p>Dispensers can interact with cauldrons</p>
<p>Dispensers can place pumpkins</p>
<p>Dispensers can carve pumpkins</p>
<p>Barrels have 54 inventory slots</p>
<p>Furnaces have a built in item filter, this moves any item they can't process to the output</p>
<p>Naming a container or villager "public" makes them publically available</p>
<p>Natual loot chests (in dungeons, etc.) are not protected by claims (but can only be broken by the claim
owner)</p>
</div>
</div>
</section>
</section>
<section class="darkmodeSection">
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<h2>Compostable items</h2>
<ul class="full-page-list">
<li>leather: 0.10</li>
<li>wooden_shovel: 0.10</li>
<li>rotten_flesh: 0.15</li>
<li>salmon: 0.15</li>
<li>cod: 0.15</li>
<li>porkchop: 0.15</li>
<li>beef: 0.15</li>
<li>chicken: 0.15</li>
<li>rabbit: 0.15</li>
<li>mutton: 0.15</li>
<li>bamboo: 0.15</li>
<li>wooden_sword: 0.20</li>
<li>wooden_hoe: 0.20</li>
<li>wooden_pickaxe: 0.30</li>
<li>wooden_axe: 0.30</li>
<li>poisonous_potato: 0.35</li>
<li>leather_boots: 0.40</li>
<li>flowering_azalea_leaves: 0.50</li>
<li>leather_helmet: 0.50</li>
<li>leather_leggings: 0.70</li>
<li>leather_chestplate: 0.80</li>
</ul>
</div>
</div>
</section>
</section>
<section class="darkmodeSectionThree">
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<h2>Ender Dragon</h2>
<p>First kill of the month awards a dragon egg</p>
<p style="padding-bottom: 0 !important;">Can drop items when killed</p>
<ul class="full-page-list">
<li>elytra: 30%</li>
<li>dragon_head: 30%</li>
<li>dragon_breath: 40%</li>
<li>and seperately from the rest, a 0.1% chance for a dragon_egg</li>
</ul>
</div>
</div>
</section>
</section>
<section class="darkmodeSection">
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<h2>Mobs</h2>
<p>Bats can spawn above sea level (bat spawning only enabled during halloween)</p>
<p style="padding-bottom: 0 !important;">Mobs can respawn in structures</p>
<ul class="full-page-list">
<li>Shulker -> End City</li>
<li>Piglin Brute, Piglin -> Bastion</li>
<li>Evoker, Vindicator, Allay -> Woodland Mansion</li>
<li>Allay -> Pillager Outpost</li>
<li>Camel -> Desert Village</li>
</ul>
<p>Naming a mob "muted", "silenced", "silent" or "silence me" silences a mob</p>
<p>Naming a mob "muted", "silenced", "silent", "silence me", "protected" or "protect me" prevents it from
being damaged by non-trusted players in a claim</p>
<p>Naming a mob "baby" prevents it from growing up</p>
<p>Wither health bar is removed when silenced</p>
<p>Turtles can hatch during daytime</p>
<p>Unclaim vanilla player pets (dogs, cats, parrots, horses) if the owner has been offline for 60+ days by
right clicking with a lead</p>
<p>Fisherman villagers can sell Bucket of Salmon</p>
</div>
</div>
</section>
</section>
<section class="darkmodeSectionThree">
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<h2>Restrictions & disabled vanilla features</h2>
<p>Restricted iron golem spawning (10 golems spawning in a radius of 112 blocks in a time span of 1
minute)</p>
<p>Disabled stacking raids (having multiple raids going on at once)</p>
<p>Disabled TNT duping</p>
<p>Disabled bat spawning</p>
<p>Disabled patrol spawning</p>
<p>Zombies don't attack villagers</p>
<p>Disabled processing of most things around AFK players</p>
<p>Disabled AI for villagers in a 1x1 area</p>
<p>Shulkers shooting each other does not spawn a new shulker since they respawn naturally in End Cities</p>
<p>Pigmen cannot spawn from portals</p>
<p>Allay duplication is disabled since they respawn natually in Mansions and Outposts</p>
<p>Players cannot claim land within 100 blocks of another claim without the owner's permission</p>
</div>
</div>
</section>
</section>
<section class="darkmodeSection">
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<h2>Miscellaneous</h2>
<p>Grindstones can strip shulker box & beehive NBT data</p>
<p>Sneak click mending</p>
<p>/iwanttobreakthisblock, required to break some natural generated blocks</p>
<p>/sneakclickmending, allows you to mend your items by right clicking while sneaking</p>
<p>Swift Sneak enchantment can be found in villager trades</p>
<p>Nether portals can be as small as 1x2 (1x2 portal, 3x4 base)</p>
<p>Lootchests refill once for every player</p>
<p>Empty maps can be duplicated using paper in a cartography table</p>
<p>Eating glowberries gives you a glowing effect</p>
</div>
</div>
</section>
</section>
</main>
</ng-container>
@@ -0,0 +1,9 @@
main ul {
font-family: opensans, sans-serif;
text-align: left;
}
main li {
margin-left: 30px;
padding-bottom: 10px;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CustomfeaturesComponent } from './customfeatures.component';
describe('CustomfeaturesComponent', () => {
let component: CustomfeaturesComponent;
let fixture: ComponentFixture<CustomfeaturesComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CustomfeaturesComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CustomfeaturesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,16 @@
import {Component} from '@angular/core';
import {HeaderComponent} from "../header/header.component";
import {RouterLink} from '@angular/router';
@Component({
selector: 'app-customfeatures',
imports: [
HeaderComponent,
RouterLink
],
templateUrl: './customfeatures.component.html',
styleUrl: './customfeatures.component.scss'
})
export class CustomfeaturesComponent {
}
@@ -0,0 +1,135 @@
<ng-container>
<app-header [current_page]="'economy'" height="460px" background_image="/public/img/backgrounds/trees.jpg"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Economy</h1>
<h2>Altitude features a dynamic in-game economy that uses virtual currency to allow for easier trading between
players.</h2>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<h2>What is the Economy?</h2>
<p>Altitude uses a combination of simple player shops and dynamic NPC merchants to create a truly
one-of-a-kind experience for our players. Shops and Merchants allow you to exchange items for virtual
currency, and vice-versa. The spawn of each server has some items that can be bought or sold by talking to
the various merchants, but player shops are the backbone of the system. These shops are created and run by
our players, and they allow those players to determine and set their own prices for their goods.</p>
<p>The virtual currency on Altitude has no value outside of the server, nor any item in-game representing
it. Each player has a number representing how much in-game currency they have. You can check this any time
with <span style="font-family: 'opensans-bold', sans-serif;">/bal</span>.</p>
</div>
<div class="columnParagraph">
<h2>Player Shops</h2>
<p>Altitude allows players to create chest shops, which other players can either buy or sell items to. Its
a really simple task to create your shops. Theres no complex commands or multi-line sign setups -
everything is done using a GUI.</p>
<p>To get started, grab a sign and place it on a container (does not work on double chests). Write [shop] on
the first line and youre ready to go. Exit the sign interface and the GUI will open. With these simple
steps you will have successfully created your first shop!
Right click the sign to manage the shop. From there you can control the shop balance, item, item amount
and price.</p>
</div>
<div class="columnParagraph">
<h2>Shop Balance</h2>
<p>Every shop will have its own balance. The balance is used to for making changes in the shop management
GUI, paying players if they sell items to the shop and receiving money when players buy items from the
shop.</p>
</div>
<!-- <div class="columnparagraph">
<h2>Shop Upkeep</h2>
<p>Once a week shop upkeep need to be paid. The amount depends on the amount of shops you have; the higher the amount of shops, the higher the fee.
The fee will be taken from the shops balance. Should there not be enough money in the balance to pay the upkeep fee the shop will be deactivated. Once it had been deactivated other players will no longer be able to interact with it until the shop owner has reactivated it.</p>
<p>A shop that hasnt been interacted with (players using the shop) for three weeks, even if it has the balance to pay for upkeep, will be deactivated and will also need to be reactivated. This is done to prevent dormant and forgotten shops.</p>
</div> -->
<div class="columnParagraph">
<h2>Economist's Tips</h2>
<p>To get the most of the in-game economy, keep these tips in mind!</p>
<ul>
<li>Use the spawn merchants to buy and sell as a last resort! These merchants have intentionally worse
prices to allow for player markets to emerge for those same items.
</li>
<li>Build multiple, smaller farms for different types of items to sell rather than one massive farm that
only generates one type of item. Youll earn more money because of the dynamic prices!
</li>
<li>Keep track of the points you have with each merchant! Knowing how many points you have will allow you
to time your buying and selling for the biggest potential profit!
</li>
<li>Remember that player shops have flat prices, while the spawn merchants have dynamic prices.</li>
<li>Try not to buy or sell when your points are at the worst levels! You will get a huge penalty for
having such an extreme amount of points and the transaction will likely not be worth it.
</li>
</ul>
</div>
</div>
<div class="columnContainer">
<div class="columnParagraph">
<h2>Spawn Merchants</h2>
<p>Every merchant at spawn buys and sells certain types of items. You can interact and trade with them
similar to how you would trade emeralds with a regular villager. Each of these merchants are located
around each of the server spawn, which you can conveniently get to by doing <span
style="font-family: 'opensans-bold', sans-serif;">/spawn</span>!</p>
<!-- INSERT IMAGE OF VILLAGER MERCHANT -->
<p>These merchants buy and sell directly from the server, not from other players. Prices from these
merchants are typically quite high for buying, and quite low for selling. This means that they should be
used as a last resort for some quick cash or some quick materials; player shops will generally be a much
better value, especially because the prices are static.</p>
</div>
<div class="columnParagraph">
<h2>The Point System</h2>
<p>The prices offered by the merchants at spawn are dynamic in nature. This means that they change based on
the amount and type of items that you've bought or sold recently. Every merchant at spawn keeps track of
how many items youve traded with it using points. The amount of points you currently have with each
merchant is shown in the trading screen. To understand the point system, all you need to know is:</p>
<ul>
<li>Positive points = higher buy prices, higher sell prices</li>
<li>Negative points = lower buy prices, lower sell prices</li>
<li>Points decay naturally over time. The decay is faster if you are online</li>
</ul>
<p>For most players, this dynamic point system wont affect you much at all. As long as you arent buying or
selling huge quantities of items, you wont notice a large change in the prices as you use the spawn
merchants.</p>
<p>However, players can take advantage of the point system to make the most amount of profit possible if
they are so inclined. To do this, you need to understand the multipliers that affect prices at different
price levels. You can see your current price multipliers and points at each merchant by doing <span
style="font-family: 'opensans-bold', sans-serif;">/villagerui points</span>.</p>
<p style="padding-bottom: 0;">There are different thresholds for each price multiplier to take effect.
Between -500 and 500 points, prices will be unaltered. Once your points go above or below that range,
prices will become more and more extreme. You can see where these thresholds are based on your current
points here:</p>
<img ngSrc="/public/img/random/pointbracket.png" alt="Visualization of the point bracket."
style="width: 100%; padding: 0 0 15px 0;"
height="100" width="800">
</div>
<div class="columnParagraph">
<h2>Useful Commands</h2>
<ul>
<li><span style="font-family: 'opensans-bold', sans-serif;">/bal -</span> Check your current balance</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/pay &lt;player&gt; &lt;amount&gt; -</span>
Pay other players
</li>
<li><span style="font-family: 'opensans-bold', sans-serif;">/shop -</span> Click on settings, from there
you can change notification settings
</li>
<li><span
style="font-family: 'opensans-bold', sans-serif;">/villagerui points &lt;villagerType&gt; -</span>
Allows you to check your current points with each spawn merchant, or any specific one. Also shows your
current buy/sell price multipliers for those merchants
</li>
<li><span
style="font-family: 'opensans-bold', sans-serif;">/villagerui buy/sell &lt;item_name&gt; -</span> Tells
you if a spawn merchant buys or sells that item, and where that merchant is located
</li>
</ul>
</div>
</div>
</section>
</section>
</main>
</ng-container>
@@ -0,0 +1,9 @@
main ul {
font-family: opensans, sans-serif;
text-align: left;
}
main li {
margin-left: 30px;
padding-bottom: 10px;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EconomyComponent } from './economy.component';
describe('EconomyComponent', () => {
let component: EconomyComponent;
let fixture: ComponentFixture<EconomyComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EconomyComponent]
})
.compileComponents();
fixture = TestBed.createComponent(EconomyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,16 @@
import {Component} from '@angular/core';
import {HeaderComponent} from '../header/header.component';
import {NgOptimizedImage} from '@angular/common';
@Component({
selector: 'app-economy',
imports: [
HeaderComponent,
NgOptimizedImage
],
templateUrl: './economy.component.html',
styleUrl: './economy.component.scss'
})
export class EconomyComponent {
}

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