103 Commits
Author SHA1 Message Date
stijn de1876c90c Add historyType and historyId to Appeal, update database schema, API, and email templates to include punishment details. 2025-08-16 23:40:20 +02:00
stijn c3f3b20807 Update mail configuration to use SSL with custom socket factory. 2025-08-16 23:01:22 +02:00
stijn 37fb49cda1 Inject SpringTemplateEngine into AppealMail and remove redundant field initialization. 2025-08-16 20:28:29 +02:00
stijn db642103ed Add email notification service for appeals using Spring Mail and Thymeleaf templates. 2025-08-16 20:23:35 +02:00
stijn f026f24263 Refactor createAppeal in AppealMapper to void return type, adjust AppealController to remove redundant UUID handling. Optimize AppealDataMapper to use UUID.randomUUID(). 2025-08-14 02:34:28 +02:00
stijn eaee31ab2b Change createAppeal method in AppealMapper to return UUID, adjust AppealController to construct Appeal with generated UUID. 2025-08-14 01:37:57 +02:00
stijn 24d7cfe913 Register UUIDTypeHandler in MyBatis configuration to handle UUID mapping in Connection.java. 2025-08-13 23:56:31 +02:00
stijn 770a2e0d14 Add Minecraft appeal functionality with database integration, UUID handling, and API response adjustments. 2025-08-13 23:54:20 +02:00
stijn 101794d8f2 Remove username property from appeal.yml schema in Discord ban appeal form. 2025-08-08 21:01:58 +02:00
stijn eb72ce14cc Integrate HistoryFormatService into AppealComponent to filter inactive history items. 2025-08-06 00:31:30 +02:00
stijn d1ba89acc8 Refactor KeyPairMapper to improve query readability by aliasing fields and reformatting SQL. 2025-08-05 23:55:15 +02:00
stijn d28b4a2b62 Refactor LoginController to use pattern matching for Jwt type check, simplify uuid extraction, and adjust debug logs. 2025-08-05 23:49:11 +02:00
stijn 56f4ccf40e Add session rollback handling in Connection.java, improve resource management, and refine debug logging in LoginController. 2025-08-05 23:46:47 +02:00
stijn d73f057596 Remove app.config.ts, integrate configuration directly in main.ts, and refactor providers to include authInterceptor. 2025-08-05 23:41:25 +02:00
stijn e825d83124 Integrate authInterceptor for JWT handling, update API schema with bearerAuth security, and refactor configuration to support HTTP interceptors. 2025-08-05 23:35:32 +02:00
stijn 238c5d9644 Refactor reloadUsername in auth.service to remove debug logs, simplify subscription handling, and add null check before invocation. 2025-08-05 23:29:37 +02:00
stijn 4222df87a3 Add debug logs to reloadUsername in auth.service for improved error and data tracking 2025-08-05 23:26:23 +02:00
stijn 16cc57d774 Update auth.service log message for clarity when saving user claims 2025-08-05 23:25:16 +02:00
stijn c536bfbf30 Add debug logs to checkAuthStatus for missing or expired JWT 2025-08-05 23:23:26 +02:00
stijn f67cb50f41 Refactor logging in LoginController, simplify auth.service token validation, and remove debug logs from AppealComponent. 2025-08-05 23:22:12 +02:00
stijn bdb38e5011 Add username retrieval functionality to LoginController using RecentNamesMapper query 2025-08-05 23:12:50 +02:00
stijn ae1e972438 Implement appeal form flow with dynamic pages, integrate punishment selection, and add username retrieval logic. Update API schema and enhance auth.service for username handling. 2025-08-05 23:11:38 +02:00
stijn 737b26a6c7 Update environment configs to include defaultAuthStatus property. 2025-08-05 21:05:38 +02:00
stijn 5013b9a204 Add pagination logic to AppealComponent and update layout structure. 2025-08-05 20:59:22 +02:00
stijn fcb64db137 Make appeal form centered and create landing page 2025-08-05 20:17:37 +02:00
stijn d2e064e2b4 Add priority attribute to Discord button image in footer for improved loading behavior. 2025-08-05 20:07:38 +02:00
stijn f50f2dc6c2 Update login form label to provide instructions for obtaining the code from the Minecraft server. 2025-08-03 01:27:49 +02:00
stijn c277306c2c Refactor FormsComponent and add AuthGuard for appeal route protection. Replace dynamic routing with static appeal route, restructure AppealComponent layout, and introduce responsive design adjustments. Update environment configuration for default auth status. 2025-08-03 00:09:27 +02:00
stijn 1f03a4bdc3 Implement AuthGuard for route protection, integrate authorization checks into particles route, and simplify HeaderComponent access logic. Remove redundant debug logging in auth.service.ts. 2025-08-02 22:27:37 +02:00
stijn 7f1c59d102 Rename authorizations to authorities in JWT interface and related method in auth.service.ts. Update debug logging accordingly. 2025-08-02 22:11:54 +02:00
stijn f968a64dd4 Add debug logging for user claims in auth.service.ts methods. 2025-08-02 22:08:56 +02:00
stijn c25364caf7 Add detailed debug logging in hasAccess method for clearer authorization checks. 2025-08-02 22:02:23 +02:00
stijn 15c3cc7f26 Remove redundant debug logging in auth.service.ts and add logging in hasAccess method for authorization checks. 2025-08-02 21:55:39 +02:00
stijn 2b96957876 Add debug logging for JWT claims extraction in auth.service.ts. 2025-08-02 21:49:41 +02:00
stijn b16fab26e7 Refactor hasAccess method in auth.service.ts to improve readability and optimize authorization checking logic. 2025-07-31 23:30:48 +02:00
stijn 28fd05a656 Update HeaderComponent HTML: adjust access check condition to use SCOPE_head_mod instead of HEAD_MOD. 2025-07-31 23:08:02 +02:00
stijn ff1b09be92 Add debug logging for resource handling in WebConfig. 2025-07-31 22:03:34 +02:00
stijn 8a839ac922 Refactor WebConfig and SecurityConfig to enhance routing with /api prefix, disable CSRF and anonymous access; update OpenAPI paths accordingly. Add HomeController for default route handling. 2025-07-31 21:32:55 +02:00
stijn 3f76a98409 Rename server.address to my-server.address across properties files and LoginController for consistency and clarity. 2025-07-30 00:18:22 +02:00
stijn 871615702b Update Node.js to v20.19.0 and npm to v10.2.3; refactor npmBuild task to use plugin-provided npmCommand and add nodeVersionCheck task for environment validation. 2025-07-30 00:08:00 +02:00
stijn 291c9df5c6 Upgrade Angular dependencies and ngx-cookie-service in package.json to latest versions. 2025-07-29 23:53:54 +02:00
stijn 4150324d75 Replace valueOf with fromValue in LoginController for permission claim mapping. 2025-07-29 23:17:32 +02:00
stijn 4267c782a7 Add debug logging for user loading, permissions processing, and token generation in LoginController. 2025-07-29 23:16:04 +02:00
stijn 343964eda8 Add server.address property and update issuer field in LoginController to use dynamic server address. Modify SQL query in PrivilegedUserMapper to simplify permissions retrieval. 2025-07-28 23:01:18 +02:00
stijn 1ce2088cae Update HeaderComponent HTML: change "Login" label to "Logout" for logout button. 2025-07-15 23:42:03 +02:00
stijn 0b952e07f7 Refactor HeaderComponent to organize inputs, inject dependencies, and improve HTML formatting. Add logout method for authentication management. 2025-07-15 21:51:03 +02:00
stijn c2b9a8a574 Replace deprecated Angular directives (*ngFor, *ngIf) with modern Angular template syntax. Remove unused CommonModule imports across components for optimization. Clean up excess spacing and formatting in HTML files. 2025-07-15 21:48:23 +02:00
stijn d3ef296784 Upgrade TypeScript to version ^5.8.3 in package.json. 2025-07-15 21:39:40 +02:00
Peter 5a792463cc Update responsive styles and class names for consistency
Refactored multiple components to improve responsive design, ensuring better usability on smaller screens. Standardized class names for clarity and consistency, and adjusted layouts and styles to enhance overall alignment and accessibility.
2025-07-14 22:49:22 +02:00
Peter 974d50d7cd Merge remote-tracking branch 'origin/master' 2025-07-06 21:33:27 +02:00
Peter 62f837914c Add new routes and improve theme and header responsiveness
Added routes for 'community', 'nicknames', and 'nickgenerator'. Enhanced theme switch positioning and responsive design for smaller screens. Moved header login button to a more consistent position and upgraded Angular Material to version 19.2.19 for better compatibility.
2025-07-06 21:33:14 +02:00
stijn ace969ba3b Remove Blob handling logic from AuthService.login and simplify JWT processing. 2025-07-06 21:00:52 +02:00
stijn 2fc6ba53f6 Handle Blob responses in AuthService.login and enhance JWT decoding logic. Add utility methods for Blob detection and conversion. 2025-07-06 20:53:13 +02:00
stijn 4c38b070ea Handle Blob responses in AuthService.login and enhance JWT decoding logic. Add utility methods for Blob detection and conversion. 2025-07-06 20:53:03 +02:00
stijn db394beda6 Add debug logs for JWT and its decoded value in AuthService. 2025-07-06 20:32:02 +02:00
stijn 76cb3cd89c Decode JWT before saving in AuthService. 2025-07-06 20:09:49 +02:00
stijn 5d8ab2deef Add debug log for generated token in LoginController. 2025-07-06 19:39:46 +02:00
stijn aef32a8982 Change log level to DEBUG in beta configuration and improve logging in LoginController. 2025-07-06 19:23:38 +02:00
stijn 42f0961f13 Directly initialize JwtHelperService in auth service 2025-07-06 19:14:06 +02:00
stijn 04310e1cce Switch to localStorage for JWT handling and simplify case transformation logic in LoginComponent. Update app.config.ts and related services to align with the new token management method. Mark JwtClaims interface as exported. 2025-07-06 19:10:17 +02:00
Peter 54e747118c Merge branch 'bans' 2025-07-06 18:15:09 +02:00
stijn cce83a08de Replace fakeLogin() with actual login() method in AuthService and remove redundant fakeLogin() implementation. 2025-07-04 23:32:08 +02:00
stijn f0faa63ca7 Add JWT support for authentication handling
Integrate `@auth0/angular-jwt` for Token management. Update `app.config.ts` with `JwtModule` setup and token getter from cookies. Enhance `AuthService` to include token handling, fake login, and JWT validation using `JwtHelperService`. Introduce `JwtClaims` interface for structured token claims.
2025-07-04 22:31:41 +02:00
stijn dfea91d8ca Add PrivilegedUserMapper to InitializeWebDb setup 2025-07-04 21:37:11 +02:00
stijn 73916f0aae Add login button to header 2025-07-04 21:14:45 +02:00
stijn ebe66c87c0 Rework folder structure in frontend
Pages are now grouped per group they appear in on in the header (where possible)
Utilities used by multiple pages in the project are grouped in folders such as services/pipes/etc
2025-07-04 19:50:21 +02:00
stijn c42fc38b2c Add SecurityAuthFailureHandler for better handling of authentication and access failures; update SecurityConfig to integrate the new handler. 2025-07-04 19:49:04 +02:00
stijn 213f9987d9 Remove particle component and its associated routes and security controls. 2025-07-03 20:08:56 +02:00
stijn 48cac607de Add route for lazy-loaded Login component. 2025-07-03 20:02:07 +02:00
stijn 6ed2e15017 Parametrize notification server URL configuration for improved flexibility. 2025-06-29 03:17:25 +02:00
stijn 7fc25f46f3 Add endpoints, services, and security controls for particle file management, including save and download APIs. 2025-06-29 03:15:39 +02:00
stijn c72703ea32 Refactor user privilege handling to use Optional instead of null checks. Remove unused cache entries and update security configuration to refine access controls. 2025-06-23 21:34:54 +02:00
auto e837a9216d Fix slider sticking out of page 2025-06-23 00:25:31 +02:00
auto d4363b3a8a Add particle type selection, size control, and enhance particle property handling 2025-06-23 00:23:03 +02:00
auto 1e5862bae6 Add new particle types and enhance particle attributes handling 2025-06-23 00:04:30 +02:00
auto daf88ea437 Add opacity control for intersection plane 2025-06-22 23:24:06 +02:00
auto 9abd570b87 Add support for darkmode 2025-06-22 23:15:06 +02:00
auto 5284d498f3 Add a reset camera button and implement default camera reset functionality 2025-06-22 20:53:11 +02:00
auto c3a7be82e9 Add an option to highlight particles 2025-06-22 20:46:08 +02:00
auto fdb57289f8 Fix weird interactions between plane and player model 2025-06-22 20:20:40 +02:00
auto 60c1329163 Switch to steve 2025-06-22 20:17:02 +02:00
auto 0e71c0f581 Fix textures overlapping and glitching for legs 2025-06-22 20:15:35 +02:00
auto eb67a33331 Change default camera position 2025-06-22 20:12:25 +02:00
auto 39f20796ce Simplify UV mapping logic and remove unnecessary face rotation handling 2025-06-22 20:08:42 +02:00
auto e00165c56f Fix uv rotation 2025-06-22 20:04:26 +02:00
auto 02c6497700 Fix mapping 2025-06-22 20:01:11 +02:00
auto 0efd476676 Initial attempt at textures 2025-06-22 19:53:27 +02:00
auto 237518638c Increase particles-list height in FramesComponent to 550px for improved visibility. 2025-06-22 19:34:20 +02:00
auto fea1a98cea Adjust RendererService to use dynamic container height and update OrbitControls limits 2025-06-22 19:27:55 +02:00
auto ecd9b3d824 Modularize renderer and plane control functionality into RenderContainerComponent 2025-06-22 19:23:54 +02:00
auto 9808b5d63d Add manual plane orientation controls with lock/unlock functionality
Implemented a UI overlay in `ParticlesComponent` for manual plane orientation selection with buttons for different orientations. Added lock/unlock toggle to control automatic orientation adjustment. Refactored `IntersectionPlaneService` to support locked state and manual orientation updates. Updated styles and layout to integrate the new controls seamlessly.
2025-06-22 19:16:32 +02:00
auto c13b7077a7 Remove unused OnInit lifecycle hook from ParticlesComponent and cleanup redundant comments. 2025-06-22 19:08:28 +02:00
auto 023ae809ef Modularize ParticlesComponent by separating particle, frame, and property functionalities into dedicated components
Refactored `ParticlesComponent` to use `ParticleComponent`, `FramesComponent`, and `PropertiesComponent` for better organization and reusability. Updated layout, improved UI structure, and centralized particle management logic. Enhanced clipboard functionality with a new JSON copy feature.
2025-06-22 19:06:52 +02:00
auto 3a6f137c9a Update ParticlesComponent to allow dynamic plane position limits and improve slider configuration
Modified plane position logic to use dynamic min/max offsets based on orientation. Enhanced slider component with binding to these offsets and updated display text for clarity. Refactored `IntersectionPlaneService` to expose new methods (`getMaxOffset`, `getMinOffset`) and adjusted position defaults.
2025-06-22 17:59:29 +02:00
auto cb8447a096 Correct default plane locations 2025-06-22 17:38:28 +02:00
auto 3e98e1a498 add plane orientation for looking from above/below 2025-06-22 17:32:32 +02:00
auto 4c31a91bb4 Add modular services for Three.js integration in ParticlesComponent
Implemented dedicated Angular services (e.g., RendererService, InputHandlerService, PlayerModelService) to modularize and simplify Three.js integration within the `ParticlesComponent`. Refactored component logic to delegate rendering, input handling, and model creation to respective services. Updated particle data models and removed redundant logic from the component.
2025-06-22 17:26:10 +02:00
auto d6faaba01c Update security configuration to allow unrestricted access
Changed default security rule to permit all requests instead of requiring authentication. Adjusted `SecurityConfig` to ensure open access for all endpoints.
2025-06-22 01:15:45 +02:00
auto 56175e62d6 Remove RemoveTrailingPeriodPipe import from AboutComponent 2025-06-22 01:10:53 +02:00
auto 283838f444 Enhance ParticlesComponent to adjust plane position dynamically based on rotation angle. 2025-06-22 01:10:21 +02:00
auto 49fe335c73 Refactor ParticlesComponent: simplify plane creation, remove legacy handler, and enhance quadrant logic with material updates. 2025-06-22 01:03:17 +02:00
auto 1875f050c6 Add orbit controls and mouse interaction to ParticlesComponent
Integrated Three.js `OrbitControls` for smoother camera navigation and implemented new mouse interaction methods (`onMouseDown`, `onMouseUp`, `onMouseMove`) for enhanced usability. Adjusted scene, camera, and renderer setup for better responsiveness.
2025-06-22 00:55:38 +02:00
auto 52d8658be3 Add particle creator component with Three.js integration CREDITS: Junie
Introduced the `ParticlesComponent` for creating and visualizing particles using Three.js. Added associated routes, HTML, SCSS, and tests. Updated `package.json` to include `three` and `@types/three` as dependencies. Adjusted app navigation to include the particles page.
2025-06-22 00:40:16 +02:00
237 changed files with 5047 additions and 1119 deletions
+2
View File
@@ -36,6 +36,8 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.security:spring-security-oauth2-resource-server") implementation("org.springframework.security:spring-security-oauth2-resource-server")
implementation("org.springframework.security:spring-security-oauth2-jose") implementation("org.springframework.security:spring-security-oauth2-jose")
implementation("org.springframework.boot:spring-boot-starter-mail:3.1.5")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
//AOP //AOP
implementation("org.aspectj:aspectjrt:1.9.19") implementation("org.aspectj:aspectjrt:1.9.19")
@@ -5,7 +5,7 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.context.annotation.EnableAspectJAutoProxy;
@SpringBootApplication @SpringBootApplication(scanBasePackages = {"com.alttd.altitudeweb"})
@EnableAspectJAutoProxy @EnableAspectJAutoProxy
public class AltitudeWebApplication { public class AltitudeWebApplication {
@@ -0,0 +1,36 @@
package com.alttd.altitudeweb.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Slf4j
@Component
public class SecurityAuthFailureHandler implements AccessDeniedHandler, AuthenticationEntryPoint {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
log.warn("Access denied: User '{}' attempted to access '{}' without proper permissions",
request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : "unknown",
request.getRequestURI());
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
log.warn("Authentication failure: Unauthenticated user attempted to access secured endpoint '{}'",
request.getRequestURI());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication Required");
}
}
@@ -14,6 +14,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoder;
@@ -31,19 +32,37 @@ import java.security.interfaces.RSAPublicKey;
public class SecurityConfig { public class SecurityConfig {
private final KeyPairService keyPairService; private final KeyPairService keyPairService;
private final SecurityAuthFailureHandler securityAuthFailureHandler;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http return http
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(
.requestMatchers("/login/userLogin/**", "/login/requestNewUserLogin/**").permitAll() auth -> auth
.requestMatchers("/team/**", "/history/**").permitAll() .requestMatchers("/api/form/**").hasAuthority(PermissionClaimDto.USER.getValue())
.requestMatchers("/form/**").hasAuthority(PermissionClaimDto.USER.getValue()) .requestMatchers("/api/login/userLogin").hasAuthority(PermissionClaimDto.USER.getValue())
.requestMatchers("/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.anyRequest().authenticated() .requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.anyRequest().permitAll()
)
.csrf(AbstractHttpConfigurer::disable)
.anonymous(AbstractHttpConfigurer::disable)
.oauth2ResourceServer(
oauth2 -> oauth2
.jwt(Customizer.withDefaults())
.authenticationEntryPoint(securityAuthFailureHandler)
.accessDeniedHandler(securityAuthFailureHandler)
)
.exceptionHandling(
ex -> ex
.authenticationEntryPoint(securityAuthFailureHandler)
.accessDeniedHandler(securityAuthFailureHandler)
)
.sessionManagement(
session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
) )
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build(); .build();
} }
@@ -1,21 +1,24 @@
package com.alttd.altitudeweb.config; package com.alttd.altitudeweb.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver; import org.springframework.web.servlet.resource.PathResourceResolver;
import java.io.IOException; import java.io.IOException;
@Configuration @Slf4j @Configuration
public class WebConfig implements WebMvcConfigurer { public class WebConfig implements WebMvcConfigurer {
@Override @Override
public void addResourceHandlers(ResourceHandlerRegistry registry) { public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**") registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/") .addResourceLocations("classpath:/static/browser")
.resourceChain(true) .resourceChain(true)
.addResolver(new PathResourceResolver() { .addResolver(new PathResourceResolver() {
@Override @Override
@@ -23,11 +26,23 @@ public class WebConfig implements WebMvcConfigurer {
Resource requestedResource = location.createRelative(resourcePath); Resource requestedResource = location.createRelative(resourcePath);
if (requestedResource.exists() && requestedResource.isReadable()) { if (requestedResource.exists() && requestedResource.isReadable()) {
log.debug("Serving resource {} from {}", resourcePath, location);
return requestedResource; return requestedResource;
} }
return new ClassPathResource("/static/index.html"); log.debug("Resource {} not found in {}, serving index.html", resourcePath, location);
return new ClassPathResource("/static/browser/index.html");
} }
}); });
} }
@Controller
public static class HomeController {
@GetMapping("/")
public String index() {
return "forward:/index.html";
}
}
} }
@@ -1,36 +0,0 @@
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,33 @@
package com.alttd.altitudeweb.controllers.data_from_auth;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.server.ResponseStatusException;
import java.util.UUID;
public class AuthenticatedUuid {
/**
* Extracts and validates the authenticated user's UUID from the JWT token.
*
* @return The UUID of the authenticated user
* @throws ResponseStatusException with 401 status if authentication is invalid
*/
public static UUID getAuthenticatedUserUuid() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
}
String stringUuid = jwt.getSubject();
try {
return UUID.fromString(stringUuid);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid UUID format");
}
}
}
@@ -0,0 +1,100 @@
package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.AppealsApi;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.litebans.HistoryType;
import com.alttd.altitudeweb.database.litebans.IdHistoryMapper;
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.mappers.AppealDataMapper;
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 com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.mail.AppealMail;
import com.alttd.altitudeweb.setup.Connection;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@AllArgsConstructor
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class AppealController implements AppealsApi {
private final AppealDataMapper mapper;
private final AppealMail appealMail;
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
@Override
public ResponseEntity<AppealResponseDto> 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) {
CompletableFuture<Appeal> appealCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Loading history by id");
try {
Appeal appeal = mapper.minecraftAppealDtoToAppeal(minecraftAppealDto);
sqlSession.getMapper(AppealMapper.class).createAppeal(appeal);
appealCompletableFuture.complete(appeal);
} catch (Exception e) {
log.error("Failed to load history count", e);
appealCompletableFuture.completeExceptionally(e);
}
});
Appeal appeal = appealCompletableFuture.join();
HistoryRecord history = getHistory(appeal.historyType(), appeal.historyId());
if (history == null) {
throw new ResponseStatusException(HttpStatusCode.valueOf(404), "History not found");
}
appealMail.sendAppealNotification(appeal, history);
AppealResponseDto appealResponseDto = new AppealResponseDto(
appeal.id().toString(),
"Your appeal has been submitted. You will be notified when it has been reviewed.",
false);
return ResponseEntity.ok().body(appealResponseDto);
}
@Override
public ResponseEntity<AppealResponseDto> updateMail(UpdateMailDto updateMailDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Updating mail is not yet supported");
}
private HistoryRecord getHistory(String type, int id) {
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<HistoryRecord> historyRecordCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
.runQuery(sqlSession -> {
log.debug("Loading history by id");
try {
HistoryRecord punishment = sqlSession.getMapper(IdHistoryMapper.class)
.getRecentHistory(historyTypeEnum, id);
historyRecordCompletableFuture.complete(punishment);
} catch (Exception e) {
log.error("Failed to load history count", e);
historyRecordCompletableFuture.completeExceptionally(e);
}
});
return historyRecordCompletableFuture.join();
}
}
@@ -1,18 +1,27 @@
package com.alttd.altitudeweb.controllers.login; package com.alttd.altitudeweb.controllers.login;
import com.alttd.altitudeweb.api.LoginApi; import com.alttd.altitudeweb.api.LoginApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.litebans.RecentNamesMapper;
import com.alttd.altitudeweb.database.litebans.UUIDHistoryMapper;
import com.alttd.altitudeweb.model.PermissionClaimDto; import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.PrivilegedUser; import com.alttd.altitudeweb.database.web_db.PrivilegedUser;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper; import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.model.UsernameDto;
import com.alttd.altitudeweb.setup.Connection; import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import com.alttd.altitudeweb.services.limits.RateLimit; import com.alttd.altitudeweb.services.limits.RateLimit;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
@@ -36,7 +45,11 @@ public class LoginController implements LoginApi {
@Value("${login.secret:#{null}}") @Value("${login.secret:#{null}}")
private String loginSecret; private String loginSecret;
private record CacheEntry(UUID uuid, Instant expiry) {} @Value("${my-server.address:#{null}}")
private String serverAddress;
private record CacheEntry(UUID uuid, Instant expiry) {
}
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>(); private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
@@ -80,24 +93,67 @@ public class LoginController implements LoginApi {
return ResponseEntity.ok(loginCode); return ResponseEntity.ok(loginCode);
} }
@Override
public ResponseEntity<UsernameDto> getUsername() {
log.debug("Loading username for logged in user");
try {
// Get authenticated UUID using the utility method
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
log.debug("Loaded username for logged in user {}", uuid);
// Create response with username
UsernameDto usernameDto = new UsernameDto();
usernameDto.setUsername(getUsername(uuid));
log.debug("Loaded username for logged in user {}", usernameDto.getUsername());
return ResponseEntity.ok(usernameDto);
} catch (ResponseStatusException e) {
// The utility method already throws proper exceptions, we just need to convert them to ResponseEntity
return ResponseEntity.status(e.getStatusCode()).build();
}
}
private String getUsername(UUID uuid) {
CompletableFuture<String> username = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
.runQuery(sqlSession -> {
log.debug("Loading all history through logged in uuid");
try {
String temp = sqlSession
.getMapper(RecentNamesMapper.class)
.getUsername(uuid.toString());
username.complete(temp);
} catch (Exception e) {
log.error("Failed to find username for uuid {}", uuid, e);
username.completeExceptionally(e);
}
});
return username.join();
}
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login") @RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
@Override @Override
public ResponseEntity<String> login(String code) { 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) { if (code == null) {
log.warn("Received null login code");
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
CacheEntry cacheEntry = cache.get(code); CacheEntry cacheEntry = cache.get(code);
if (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now())) { if (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now())) {
log.warn("Received invalid login code {}", code);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
} }
String token = generateToken(cacheEntry.uuid); String token = generateToken(cacheEntry.uuid);
log.debug("Generated token for user {} with token {}", cacheEntry.uuid, token);
cache.remove(code); cache.remove(code);
log.debug("Generated token for user {}", cacheEntry.uuid);
return ResponseEntity.ok(token); return ResponseEntity.ok(token);
} }
@@ -134,34 +190,44 @@ public class LoginController implements LoginApi {
Instant now = Instant.now(); Instant now = Instant.now();
//TODO make a JWT for renewing and one for storing permissions for a session (expiry 1 hour) //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)); Instant expiryTime = now.plusSeconds(TimeUnit.DAYS.toSeconds(30));
CompletableFuture<PrivilegedUser> privilegedUserCompletableFuture = new CompletableFuture<>(); CompletableFuture<Optional<PrivilegedUser>> privilegedUserCompletableFuture = new CompletableFuture<>();
List<PermissionClaimDto> claimList = new ArrayList<>(); List<PermissionClaimDto> claimList = new ArrayList<>();
Connection.getConnection(Databases.DEFAULT) Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> { .runQuery(sqlSession -> {
try { try {
PrivilegedUser privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class) log.debug("Loading user by uuid {}", uuid.toString());
.getUserByUuid(uuid.toString()); PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class);
Optional<PrivilegedUser> privilegedUser = mapper
.getUserByUuid(uuid);
if (privilegedUser.isEmpty()) {
int privilegedUserId = mapper.createPrivilegedUser(uuid);
privilegedUserCompletableFuture.complete(
Optional.of(new PrivilegedUser(privilegedUserId, uuid, List.of())));
} else {
privilegedUserCompletableFuture.complete(privilegedUser); privilegedUserCompletableFuture.complete(privilegedUser);
}
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to load user by uuid", e); log.error("Failed to load user by uuid", e);
privilegedUserCompletableFuture.completeExceptionally(e); privilegedUserCompletableFuture.completeExceptionally(e);
} }
}); });
PrivilegedUser privilegedUser = privilegedUserCompletableFuture.join(); Optional<PrivilegedUser> privilegedUser = privilegedUserCompletableFuture.join();
claimList.add(PermissionClaimDto.USER); claimList.add(PermissionClaimDto.USER);
if (privilegedUser != null) { privilegedUser.ifPresent(user -> user.getPermissions().forEach(permission -> {
privilegedUser.getPermissions().forEach(permission -> {
try { try {
claimList.add(PermissionClaimDto.valueOf(permission)); claimList.add(PermissionClaimDto.fromValue(permission));
log.debug("Added permission claim {}", permission);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
log.warn("Received invalid permission claim: {}", permission); log.warn("Received invalid permission claim: {}", permission);
} }
}); }));
} log.debug("Generated token for user {} with claims {}", uuid.toString(),
claimList.stream().map(PermissionClaimDto::getValue).toList());
JwtClaimsSet claims = JwtClaimsSet.builder() JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("altitudeweb") .issuer(serverAddress)
.claim("authorities", claimList.stream().map(PermissionClaimDto::getValue).toList()) .claim("authorities",
claimList.stream().map(PermissionClaimDto::getValue).toList())
.issuedAt(now) .issuedAt(now)
.expiresAt(expiryTime) .expiresAt(expiryTime)
.subject(uuid.toString()) .subject(uuid.toString())
@@ -0,0 +1,176 @@
package com.alttd.altitudeweb.controllers.particles;
import com.alttd.altitudeweb.api.ParticlesApi;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@Slf4j
@RequiredArgsConstructor
@RestController
public class ParticleController implements ParticlesApi {
@Value("${login.secret:#{null}}")
private String loginSecret;
@Value("${particles.file_path}")
private String particlesFilePath;
@Value("${notification.server.url:http://localhost:8080}")
private String notificationServerUrl;
@Override
public ResponseEntity<Resource> downloadFile(String authorization, String filename) throws Exception {
if (authorization == null || !authorization.equals(loginSecret)) {
return ResponseEntity.status(401).build();
}
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not downloading particles file", particlesFilePath);
return ResponseEntity.status(404).build();
}
File targetFile = new File(file, filename);
return getFileForDownload(targetFile, filename);
}
@Override
public ResponseEntity<Resource> downloadFileForUser(String authorization, String uuid, String filename) throws Exception {
if (authorization == null || !authorization.equals(loginSecret)) {
return ResponseEntity.status(401).build();
}
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not downloading particles user file", particlesFilePath);
return ResponseEntity.status(404).build();
}
File targetDir = new File(file, uuid);
if (targetDir.exists()) {
return getFileForDownload(targetDir, filename);
} else {
log.warn("User {} does not have a directory for particles files", uuid);
return ResponseEntity.notFound().build();
}
}
private ResponseEntity<Resource> getFileForDownload(File file, String filename) {
File targetFile = new File(file, filename);
if (!targetFile.exists()) {
log.warn("Particles file {} does not exist", targetFile.getAbsolutePath());
return ResponseEntity.notFound().build();
}
if (!targetFile.isFile()) {
log.warn("Particles file {} is not a file", targetFile.getAbsolutePath());
return ResponseEntity.status(404).build();
}
try {
Path path = targetFile.toPath();
ByteArrayResource resource = new ByteArrayResource(Files.readAllBytes(path));
return ResponseEntity.ok()
.contentLength(targetFile.length())
.contentType(MediaType.APPLICATION_JSON)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.body(resource);
} catch (IOException e) {
log.error("Failed to read particles file {}: {}", targetFile.getAbsolutePath(), e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@Override
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) throws Exception {
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not saving particles file", particlesFilePath);
return ResponseEntity.status(404).build();
}
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
notifyServerOfFileUpload(filename);
return voidResponseEntity;
}
@Override
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) throws Exception {
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not saving particles user file", particlesFilePath);
return ResponseEntity.status(404).build();
}
File targetDir = new File(file, uuid);
if (!file.exists()) {
log.debug("Creating particles directory {}", targetDir.getAbsolutePath());
if (targetDir.mkdirs()) {
log.info("Created particles user directory {}", targetDir.getAbsolutePath());
}
}
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
notifyServerOfFileUpload(uuid, filename);
return voidResponseEntity;
}
private void notifyServerOfFileUpload(String filename) {
String notificationUrl = String.format("%s/notify/%s.json", notificationServerUrl, filename);
sendNotification(notificationUrl, String.format("file upload: %s", filename));
}
private void notifyServerOfFileUpload(String uuid, String filename) {
String notificationUrl = String.format("%s/notify/%s/%s.json", notificationServerUrl, uuid, filename);
sendNotification(notificationUrl, String.format("file upload for user %s: %s", uuid, filename));
}
private void sendNotification(String notificationUrl, String logDescription) {
try {
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response = restTemplate.getForEntity(notificationUrl, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
log.info("Successfully notified server of {}", logDescription);
} else {
log.warn("Failed to notify server of {}, status: {}",
logDescription, response.getStatusCode());
}
} catch (Exception e) {
log.error("Error notifying server of {}", logDescription, e);
}
}
private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) {
File targetFile = new File(dir, filename);
if (!Files.isWritable(targetFile.toPath())) {
log.error("Particles file {} is not writable", targetFile.getAbsolutePath());
return ResponseEntity.status(403).build();
}
if (targetFile.exists()) {
log.warn("Overwriting existing particles file {}", targetFile.getAbsolutePath());
}
try {
content.transferTo(targetFile);
} catch (Exception e) {
log.error("Failed to write particles file {}", targetFile.getAbsolutePath(), e);
return ResponseEntity.status(500).build();
}
return ResponseEntity.ok().build();
}
}
@@ -0,0 +1,32 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import com.alttd.altitudeweb.model.MinecraftAppealDto;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
public class AppealDataMapper {
public MinecraftAppealDto appealToMinecraftAppealDto(Appeal appeal) {
MinecraftAppealDto minecraftAppealDto = new MinecraftAppealDto();
minecraftAppealDto.setAppeal(appeal.reason());
minecraftAppealDto.setUsername(appeal.username());
minecraftAppealDto.setUuid(appeal.uuid());
minecraftAppealDto.setEmail(appeal.email());
return minecraftAppealDto;
}
public Appeal minecraftAppealDtoToAppeal(MinecraftAppealDto minecraftAppealDto) {
return new Appeal(
UUID.randomUUID(),
minecraftAppealDto.getUuid(),
minecraftAppealDto.getUsername(),
minecraftAppealDto.getAppeal(),
null,
null,
minecraftAppealDto.getEmail(),
null
);
}
}
@@ -0,0 +1,64 @@
package com.alttd.altitudeweb.services.mail;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
@Slf4j
@Service
@RequiredArgsConstructor
public class AppealMail {
private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
@Value("${spring.mail.username}")
private String fromEmail;
private static final String APPEAL_EMAIL = "[email protected]";
/**
* Sends an email notification about the appeal to both the user and the appeals team.
*
* @param appeal The appeal object containing all necessary information
*/
public void sendAppealNotification(Appeal appeal, HistoryRecord history) {
try {
sendEmailToAppealsTeam(appeal, history);
log.info("Appeal notification emails sent successfully for appeal ID: {}", appeal.id());
} catch (Exception e) {
log.error("Failed to send appeal notification emails for appeal ID: {}", appeal.id(), e);
}
}
private void sendEmailToAppealsTeam(Appeal appeal, HistoryRecord history) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail);
helper.setTo(APPEAL_EMAIL);
helper.setReplyTo(appeal.email());
helper.setSubject("New Appeal Submitted - " + appeal.username());
Context context = new Context();
context.setVariable("appeal", appeal);
context.setVariable("history", history);
context.setVariable("createdAt", appeal.createdAt().toString());
context.setVariable("active", history.getUntil() <= 0 || history.getUntil() > System.currentTimeMillis());
String content = templateEngine.process("appeal-email", context);
helper.setText(content, true);
mailSender.send(message);
}
}
@@ -5,4 +5,5 @@ database.host=${DB_HOST:localhost}
database.user=${DB_USER:root} database.user=${DB_USER:root}
database.password=${DB_PASSWORD:root} database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:https://beta.alttd.com} cors.allowed-origins=${CORS:https://beta.alttd.com}
logging.level.com.alttd.altitudeweb=INFO my-server.address=${SERVER_ADDRESS:https://beta.alttd.com}
logging.level.com.alttd.altitudeweb=DEBUG
@@ -5,4 +5,5 @@ database.host=${DB_HOST:localhost}
database.user=${DB_USER:root} database.user=${DB_USER:root}
database.password=${DB_PASSWORD:root} database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:http://localhost:4200} cors.allowed-origins=${CORS:http://localhost:4200}
my-server.address=${SERVER_ADDRESS:http://localhost}
logging.level.com.alttd.altitudeweb=DEBUG logging.level.com.alttd.altitudeweb=DEBUG
@@ -6,4 +6,15 @@ database.user=${DB_USER:root}
database.password=${DB_PASSWORD:root} database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:https://alttd.com} cors.allowed-origins=${CORS:https://alttd.com}
login.secret=${LOGIN_SECRET:SET_TOKEN} login.secret=${LOGIN_SECRET:SET_TOKEN}
particles.file_path=${user.home}/.altitudeweb/particles
notification.server.url=${SERVER_IP:10.0.0.107}:${SERVER_PORT:8080}
my-server.address=${SERVER_ADDRESS:https://alttd.com}
logging.level.com.alttd.altitudeweb=INFO logging.level.com.alttd.altitudeweb=INFO
discord.token=${DISCORD_TOKEN}
spring.mail.host=${MAIL_HOST:smtp.zoho.com}
spring.mail.port=${MAIL_PORT:465}
spring.mail.username=${MAIL_USER}
spring.mail.password=${MAIL_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.ssl.enable=true
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
<meta charset="UTF-8">
<title>Appeal Notification</title>
<style>
ul {
list-style-type: none;
padding-left: 20px;
}
</style>
</head>
<body>
<h2 th:text="'Appeal by ' + ${appeal.username}">Appeal by Username</h2>
<p>Punishment information</p>
<ul>
<li><strong>Username:</strong> <span th:text="${appeal.username}">username</span></li>
<li><strong>UUID:</strong> <span th:text="${appeal.uuid}">uuid</span></li>
<li><strong>Email:</strong> <span th:text="${appeal.email}">email</span></li>
<li><strong>Submitted at:</strong> <span th:text="${createdAt}">date</span></li>
<li><strong>Reason:</strong> <span th:text="${history.reason}">reason</span></li>
<li><strong>Active:</strong> <span th:text="${active}">unknown</span></li>
</ul>
<h3>Appeal:</h3>
<p th:text="${appeal.reason}">Reason text</p>
</body>
</html>
@@ -7,6 +7,16 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
public interface RecentNamesMapper { public interface RecentNamesMapper {
@Select("""
SELECT DISTINCT name AS username
FROM litebans_history
WHERE uuid = #{uuid}
ORDER BY date DESC;
""")
String getUsername(@Param("uuid") String uuid);
@Select(""" @Select("""
SELECT DISTINCT user_lookup.name AS punished_name SELECT DISTINCT user_lookup.name AS punished_name
FROM ${tableName} AS punishment FROM ${tableName} AS punishment
@@ -4,7 +4,12 @@ import org.apache.ibatis.annotations.*;
public interface KeyPairMapper { public interface KeyPairMapper {
@Select("SELECT * FROM key_pair ORDER BY id DESC LIMIT 1") @Select("""
SELECT id, private_key AS privateKey, public_key AS publicKey, created_at AS createdAt
FROM key_pair
ORDER BY id
DESC LIMIT 1
""")
KeyPairEntity getKeyPair(); KeyPairEntity getKeyPair();
@Insert(""" @Insert("""
@@ -5,12 +5,13 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.List; import java.util.List;
import java.util.UUID;
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class PrivilegedUser { public class PrivilegedUser {
private int id; private int id;
private String uuid; private UUID uuid;
private List<String> permissions; private List<String> permissions;
} }
@@ -1,21 +1,23 @@
package com.alttd.altitudeweb.database.web_db; package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import org.jetbrains.annotations.Nullable;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface PrivilegedUserMapper { public interface PrivilegedUserMapper {
/** /**
* Retrieves a user by their UUID along with their permissions * Retrieves a user by their UUID along with their permissions
* @param uuid The UUID of the user to retrieve * @param uuid The UUID of the user to retrieve
* @return The PrivilegedUser with their permissions, or null if not found * @return The optional PrivilegedUser with their permissions
*/ */
@Select(""" @Select("""
SELECT privileged_users.id, privileged_users.uuid, privileges.privileges as permission SELECT id, uuid
FROM privileged_users FROM privileged_users
LEFT JOIN privileges ON privileged_users.id = privileges.user_id WHERE uuid = #{uuid}
WHERE privileged_users.uuid = #{uuid}
""") """)
@Results({ @Results({
@Result(property = "id", column = "id"), @Result(property = "id", column = "id"),
@@ -23,7 +25,7 @@ public interface PrivilegedUserMapper {
@Result(property = "permissions", column = "id", javaType = List.class, @Result(property = "permissions", column = "id", javaType = List.class,
many = @Many(select = "getPermissionsForUser")) many = @Many(select = "getPermissionsForUser"))
}) })
PrivilegedUser getUserByUuid(@Param("uuid") String uuid); Optional<PrivilegedUser> getUserByUuid(@Param("uuid") UUID uuid);
/** /**
* Retrieves all privileged users with their permissions * Retrieves all privileged users with their permissions
@@ -99,4 +101,11 @@ public interface PrivilegedUserMapper {
WHERE user_id = #{userId} AND privileges = #{permission} WHERE user_id = #{userId} AND privileges = #{permission}
""") """)
int removePermissionFromUser(@Param("userId") int userId, @Param("permission") String permission); int removePermissionFromUser(@Param("userId") int userId, @Param("permission") String permission);
@Insert("""
INSERT INTO privileged_users (uuid)
VALUES (#{uuid})
""")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int createPrivilegedUser(UUID uuid);
} }
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.database.web_db.forms;
import java.time.Instant;
import java.util.UUID;
public record Appeal(
UUID id,
UUID uuid,
String historyType,
Integer historyId,
String username,
String reason,
Instant createdAt,
Instant sendAt,
String email,
Long assignedTo
) {
}
@@ -0,0 +1,29 @@
package com.alttd.altitudeweb.database.web_db.forms;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface AppealMapper {
@Insert("""
INSERT INTO appeals (uuid, username, historyType, historyId, reason, created_at, send_at, e_mail, assigned_to)
VALUES (#{uuid}, #{username}, #{historyType}, #{historyId}, #{reason}, #{createdAt}, #{sendAt}, #{email}, #{assignedTo})
""")
void createAppeal(Appeal appeal);
@Select("""
SELECT id, uuid, historyType, historyId, reason, created_at AS createdAt, send_at AS sendAt, e_mail AS email, assigned_to AS assignedTo
FROM appeals
WHERE id = #{id}
""")
Appeal getAppealById(int id);
@Select("""
SELECT id, uuid, historyType, historyId, reason, created_at AS createdAt, send_at AS sendAt, e_mail AS email, assigned_to AS assignedTo
FROM appeals
WHERE uuid = #{uuid}
""")
List<Appeal> getAppealsByUuid(String uuid);
}
@@ -3,6 +3,7 @@ package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.DatabaseSettings; import com.alttd.altitudeweb.database.web_db.DatabaseSettings;
import com.alttd.altitudeweb.database.web_db.SettingsMapper; import com.alttd.altitudeweb.database.web_db.SettingsMapper;
import com.alttd.altitudeweb.type_handler.UUIDTypeHandler;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.datasource.pooled.PooledDataSource; import org.apache.ibatis.datasource.pooled.PooledDataSource;
import org.apache.ibatis.mapping.Environment; import org.apache.ibatis.mapping.Environment;
@@ -13,6 +14,7 @@ import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import java.util.HashMap; import java.util.HashMap;
import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -97,10 +99,20 @@ public class Connection {
sqlSessionFactory = createSqlSessionFactory(settings, addMappers); sqlSessionFactory = createSqlSessionFactory(settings, addMappers);
} }
try (SqlSession session = sqlSessionFactory.openSession()) { SqlSession session = null;
try {
session = sqlSessionFactory.openSession();
consumer.accept(session); consumer.accept(session);
session.commit();
} catch (Exception e) { } catch (Exception e) {
if (session != null) {
session.rollback();
}
log.error("Failed to run query", e); log.error("Failed to run query", e);
} finally {
if (session != null) {
session.close();
}
} }
}).start(); }).start();
} }
@@ -114,6 +126,7 @@ public class Connection {
dataSource.setPassword(settings.password()); dataSource.setPassword(settings.password());
Environment environment = new Environment("production", new JdbcTransactionFactory(), dataSource); Environment environment = new Environment("production", new JdbcTransactionFactory(), dataSource);
Configuration configuration = new Configuration(environment); Configuration configuration = new Configuration(environment);
configuration.getTypeHandlerRegistry().register(UUID.class, UUIDTypeHandler.class);
addMappers.apply(configuration); addMappers.apply(configuration);
return new SqlSessionFactoryBuilder().build(configuration); return new SqlSessionFactoryBuilder().build(configuration);
@@ -2,7 +2,9 @@ package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.KeyPairMapper; import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.database.web_db.SettingsMapper; import com.alttd.altitudeweb.database.web_db.SettingsMapper;
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSession;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@@ -18,12 +20,15 @@ public class InitializeWebDb {
Connection.getConnection(Databases.DEFAULT, (configuration) -> { Connection.getConnection(Databases.DEFAULT, (configuration) -> {
configuration.addMapper(SettingsMapper.class); configuration.addMapper(SettingsMapper.class);
configuration.addMapper(KeyPairMapper.class); configuration.addMapper(KeyPairMapper.class);
configuration.addMapper(PrivilegedUserMapper.class);
configuration.addMapper(AppealMapper.class);
}).join() }).join()
.runQuery(SqlSession -> { .runQuery(sqlSession -> {
createSettingsTable(SqlSession); createSettingsTable(sqlSession);
createKeyTable(SqlSession); createKeyTable(sqlSession);
createPrivilegedUsersTable(SqlSession); createPrivilegedUsersTable(sqlSession);
createPrivilegesTable(SqlSession); createPrivilegesTable(sqlSession);
createAppealTable(sqlSession);
}); });
log.debug("Initialized WebDb"); log.debug("Initialized WebDb");
} }
@@ -68,7 +73,7 @@ public class InitializeWebDb {
String query = """ String query = """
CREATE TABLE IF NOT EXISTS privileged_users ( CREATE TABLE IF NOT EXISTS privileged_users (
id int NOT NULL AUTO_INCREMENT PRIMARY KEY, id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
uuid VARCHAR(36) NOT NULL uuid UUID UNIQUE NOT NULL
); );
"""; """;
try (Statement statement = sqlSession.getConnection().createStatement()) { try (Statement statement = sqlSession.getConnection().createStatement()) {
@@ -97,4 +102,27 @@ public class InitializeWebDb {
} }
} }
private static void createAppealTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS appeals (
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
uuid UUID NOT NULL,
historyType VARCHAR(16) NOT NULL,
historyId BIGINT UNSIGNED NOT NULL,
username VARCHAR(16) NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
send_at TIMESTAMP NULL,
e_mail TEXT NOT NULL,
assigned_to BIGINT UNSIGNED NULL,
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
} }
+37 -13
View File
@@ -15,11 +15,12 @@
"prefix": "app", "prefix": "app",
"architect": { "architect": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:browser", "builder": "@angular/build:application",
"options": { "options": {
"outputPath": "dist", "outputPath": {
"base": "dist"
},
"index": "src/index.html", "index": "src/index.html",
"main": "src/main.ts",
"polyfills": [ "polyfills": [
"zone.js" "zone.js"
], ],
@@ -34,7 +35,8 @@
"styles": [ "styles": [
"src/styles.scss" "src/styles.scss"
], ],
"scripts": [] "scripts": [],
"browser": "src/main.ts"
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -47,9 +49,7 @@
"optimization": true, "optimization": true,
"outputHashing": "all", "outputHashing": "all",
"sourceMap": false, "sourceMap": false,
"namedChunks": false, "namedChunks": false
"vendorChunk": false,
"buildOptimizer": true
}, },
"development": { "development": {
"sourceMap": true, "sourceMap": true,
@@ -71,14 +71,12 @@
"optimization": true, "optimization": true,
"outputHashing": "all", "outputHashing": "all",
"sourceMap": false, "sourceMap": false,
"namedChunks": false, "namedChunks": false
"vendorChunk": false,
"buildOptimizer": true
} }
} }
}, },
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular/build:dev-server",
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "frontend:build:production" "buildTarget": "frontend:build:production"
@@ -90,10 +88,10 @@
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n" "builder": "@angular/build:extract-i18n"
}, },
"test": { "test": {
"builder": "@angular-devkit/build-angular:karma", "builder": "@angular/build:karma",
"options": { "options": {
"polyfills": [ "polyfills": [
"zone.js", "zone.js",
@@ -119,5 +117,31 @@
}, },
"cli": { "cli": {
"analytics": false "analytics": false
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
} }
} }
+26 -26
View File
@@ -8,8 +8,9 @@ plugins {
node { node {
download.set(true) download.set(true)
version.set("22.14.0") // Update to the version that's compatible with your environment requirements
npmVersion.set("10.9.2") version.set("20.19.0")
npmVersion.set("10.2.3") // A compatible npm version for Node.js 20.19.0
workDir.set(file("${project.projectDir}/node")) workDir.set(file("${project.projectDir}/node"))
npmWorkDir.set(file("${project.projectDir}/node")) npmWorkDir.set(file("${project.projectDir}/node"))
} }
@@ -21,38 +22,37 @@ tasks.register<Delete>("cleanDist") {
} }
// Create a task that will run npm build // Create a task that will run npm build
tasks.register("npmBuild") { tasks.register<com.github.gradle.node.npm.task.NpmTask>("npmBuild") {
description = "Run 'npm run build'" description = "Run 'npm run build'"
group = "build" group = "build"
doLast { // Determine which build script to run based on the OS
// 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") val isWindows = System.getProperty("os.name").lowercase().contains("windows")
npmCommand.set(listOf("run", if (isWindows) "build:dev" else "build:beta"))
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") dependsOn("npmInstall")
} }
// Add a new task to check Node.js and npm versions
tasks.register<com.github.gradle.node.task.NodeTask>("nodeVersionCheck") {
description = "Check Node.js and npm versions"
script.set(file("${projectDir}/node-version-check.js"))
doFirst {
// Create a temporary script to check versions
file("${projectDir}/node-version-check.js").writeText("""
console.log('Node.js version:', process.version);
console.log('npm version:', require('npm/package.json').version);
console.log('Build command that would be used:', process.platform === 'win32' ? 'build:dev' : 'build:beta');
""".trimIndent())
}
doLast {
// Clean up the temporary script
delete("${projectDir}/node-version-check.js")
}
}
tasks.named("assemble") { tasks.named("assemble") {
dependsOn("npmBuild") dependsOn("npmBuild")
} }
+17 -14
View File
@@ -13,24 +13,27 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^19.2.18", "@angular/cdk": "^20.1.3",
"@angular/common": "^19.2.0", "@angular/common": "^20.1.0",
"@angular/compiler": "^19.2.0", "@angular/compiler": "^20.1.0",
"@angular/core": "^19.2.0", "@angular/core": "^20.1.0",
"@angular/forms": "^19.2.0", "@angular/forms": "^20.1.0",
"@angular/material": "^19.2.18", "@angular/material": "^20.1.3",
"@angular/platform-browser": "^19.2.0", "@angular/platform-browser": "^20.1.0",
"@angular/platform-browser-dynamic": "^19.2.0", "@angular/platform-browser-dynamic": "^20.1.0",
"@angular/router": "^19.2.0", "@angular/router": "^20.1.0",
"ngx-cookie-service": "^19.1.2", "@auth0/angular-jwt": "^5.2.0",
"@types/three": "^0.177.0",
"ngx-cookie-service": "^20.0.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"three": "^0.177.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^19.2.5", "@angular/build": "^20.1.0",
"@angular/cli": "^19.2.5", "@angular/cli": "^20.1.0",
"@angular/compiler-cli": "^19.2.0", "@angular/compiler-cli": "^20.1.0",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0", "jasmine-core": "~5.6.0",
"karma": "~6.4.0", "karma": "~6.4.0",
@@ -38,6 +41,6 @@
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2" "typescript": "^5.8.3"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

-21
View File
@@ -1,21 +0,0 @@
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({
selector: 'app-about',
standalone: true,
imports: [
CommonModule,
HeaderComponent,
RemoveTrailingPeriodPipe
],
templateUrl: './about.component.html',
styleUrl: './about.component.scss'
})
export class AboutComponent {
constructor(public scrollService: ScrollService) {
}
}
+4 -4
View File
@@ -1,8 +1,8 @@
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 '@custom-types/constant';
import {Router, RouterOutlet} from '@angular/router'; import {Router, RouterOutlet} from '@angular/router';
import {FooterComponent} from './footer/footer.component'; import {FooterComponent} from '@pages/footer/footer/footer.component';
@Component({ @Component({
standalone: true, standalone: true,
@@ -10,8 +10,8 @@ import {FooterComponent} from './footer/footer.component';
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
imports: [ imports: [
FooterComponent, RouterOutlet,
RouterOutlet FooterComponent
] ]
}) })
export class AppComponent implements OnInit { export class AppComponent implements OnInit {
-8
View File
@@ -1,8 +0,0 @@
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({eventCoalescing: true}), provideRouter(routes)]
};
+52 -29
View File
@@ -1,110 +1,133 @@
import {Routes} from '@angular/router'; import {Routes} from '@angular/router';
import {AuthGuard} from './guards/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
{ {
path: '', path: '',
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent) loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent)
},
{
path: 'particles',
loadComponent: () => import('./pages/particles/particles.component').then(m => m.ParticlesComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_head_mod']
}
}, },
{ {
path: 'map', path: 'map',
loadComponent: () => import('./map/map.component').then(m => m.MapComponent) loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent)
}, },
{ {
path: 'rules', path: 'rules',
loadComponent: () => import('./rules/rules.component').then(m => m.RulesComponent) loadComponent: () => import('./pages/reference/rules/rules.component').then(m => m.RulesComponent)
}, },
{ {
path: 'vote', path: 'vote',
loadComponent: () => import('./vote/vote.component').then(m => m.VoteComponent) loadComponent: () => import('./pages/vote/vote.component').then(m => m.VoteComponent)
}, },
{ {
path: 'about', path: 'about',
loadComponent: () => import('./about/about.component').then(m => m.AboutComponent) loadComponent: () => import('./pages/altitude/about/about.component').then(m => m.AboutComponent)
}, },
{ {
path: 'socials', path: 'socials',
loadComponent: () => import('./socials/socials.component').then(m => m.SocialsComponent) loadComponent: () => import('./pages/altitude/socials/socials.component').then(m => m.SocialsComponent)
}, },
{ {
path: 'team', path: 'team',
loadComponent: () => import('./team/team.component').then(m => m.TeamComponent) loadComponent: () => import('./pages/altitude/team/team.component').then(m => m.TeamComponent)
}, },
{ {
path: 'birthdays', path: 'birthdays',
loadComponent: () => import('./birthdays/birthdays.component').then(m => m.BirthdaysComponent) loadComponent: () => import('./pages/altitude/birthdays/birthdays.component').then(m => m.BirthdaysComponent)
}, },
{ {
path: 'terms', path: 'terms',
loadComponent: () => import('./terms/terms.component').then(m => m.TermsComponent) loadComponent: () => import('./pages/footer/terms/terms.component').then(m => m.TermsComponent)
}, },
{ {
path: 'privacy', path: 'privacy',
loadComponent: () => import('./privacy/privacy.component').then(m => m.PrivacyComponent) loadComponent: () => import('./pages/footer/privacy/privacy.component').then(m => m.PrivacyComponent)
}, },
{ {
path: 'bans', path: 'bans',
loadComponent: () => import('./bans/bans.component').then(m => m.BansComponent) loadComponent: () => import('./pages/reference/bans/bans.component').then(m => m.BansComponent)
}, },
{ {
path: 'bans/:type/:id', path: 'bans/:type/:id',
loadComponent: () => import('./bans/details/details.component').then(m => m.DetailsComponent) loadComponent: () => import('./pages/reference/bans/details/details.component').then(m => m.DetailsComponent)
}, },
{ {
path: 'economy', path: 'economy',
loadComponent: () => import('./economy/economy.component').then(m => m.EconomyComponent) loadComponent: () => import('./pages/features/economy/economy.component').then(m => m.EconomyComponent)
}, },
{ {
path: 'claiming', path: 'claiming',
loadComponent: () => import('./claiming/claiming.component').then(m => m.ClaimingComponent) loadComponent: () => import('./pages/features/claiming/claiming.component').then(m => m.ClaimingComponent)
}, },
{ {
path: 'mypet', path: 'mypet',
loadComponent: () => import('./mypet/mypet.component').then(m => m.MypetComponent) loadComponent: () => import('./pages/features/mypet/mypet.component').then(m => m.MypetComponent)
}, },
{ {
path: 'warps', path: 'warps',
loadComponent: () => import('./warps/warps.component').then(m => m.WarpsComponent) loadComponent: () => import('./pages/features/warps/warps.component').then(m => m.WarpsComponent)
}, },
{ {
path: 'skyblock', path: 'skyblock',
loadComponent: () => import('./skyblock/skyblock.component').then(m => m.SkyblockComponent) loadComponent: () => import('./pages/features/skyblock/skyblock.component').then(m => m.SkyblockComponent)
}, },
{ {
path: 'customfeatures', path: 'customfeatures',
loadComponent: () => import('./customfeatures/customfeatures.component').then(m => m.CustomfeaturesComponent) loadComponent: () => import('./pages/features/customfeatures/customfeatures.component').then(m => m.CustomfeaturesComponent)
}, },
{ {
path: 'guide', path: 'guide',
loadComponent: () => import('./guide/guide.component').then(m => m.GuideComponent) loadComponent: () => import('./pages/reference/guide/guide.component').then(m => m.GuideComponent)
}, },
{ {
path: 'ranks', path: 'ranks',
loadComponent: () => import('./ranks/ranks.component').then(m => m.RanksComponent) loadComponent: () => import('./pages/reference/ranks/ranks.component').then(m => m.RanksComponent)
}, },
{ {
path: 'commandlist', path: 'commandlist',
loadComponent: () => import('./commandlist/commandlist.component').then(m => m.CommandlistComponent) loadComponent: () => import('./pages/reference/commandlist/commandlist.component').then(m => m.CommandlistComponent)
}, },
{ {
path: 'mapart', path: 'mapart',
loadComponent: () => import('./mapart/mapart.component').then(m => m.MapartComponent) loadComponent: () => import('./pages/reference/mapart/mapart.component').then(m => m.MapartComponent)
}, },
{ {
path: 'lag', path: 'lag',
loadComponent: () => import('./lag/lag.component').then(m => m.LagComponent) loadComponent: () => import('./pages/reference/lag/lag.component').then(m => m.LagComponent)
}, },
{ {
path: 'staffpowers', path: 'staffpowers',
loadComponent: () => import('./staffpowers/staffpowers.component').then(m => m.StaffpowersComponent) loadComponent: () => import('./pages/reference/staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
}, },
{ {
path: 'forms/:form', path: 'forms/appeal',
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent) loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_user']
}
}, },
{ {
path: 'forms', path: 'forms',
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent) loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
},
{
path: 'community',
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)
},
{
path: 'nicknames',
loadComponent: () => import('./pages/reference/nicknames/nicknames.component').then(m => m.NicknamesComponent)
},
{
path: 'nickgenerator',
loadComponent: () => import('./pages/reference/nickgenerator/nickgenerator.component').then(m => m.NickgeneratorComponent)
}, },
]; ];
-90
View File
@@ -1,90 +0,0 @@
<ng-container>
<app-header [current_page]="'bans'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">>
<div class="title" header-content>
<h1>Minecraft Punishments</h1>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<div class="container">
<div class="columnSection">
<div class="historyButtonContainer">
<div [id]="getCurrentButtonId('all')" class="button-outer" (click)="changeHistoryPunishment('all')">
<span class="button-inner"
[ngClass]="active">All</span>
</div>
<div [id]="getCurrentButtonId('ban')" class="button-outer" (click)="changeHistoryPunishment('ban')">
<span class="button-inner"
[ngClass]="active">Bans</span>
</div>
<div [id]="getCurrentButtonId('mute')" class="button-outer" (click)="changeHistoryPunishment('mute')">
<span class="button-inner"
[ngClass]="active">Mutes</span>
</div>
<div [id]="getCurrentButtonId('warn')" class="button-outer" (click)="changeHistoryPunishment('warn')">
<span class="button-inner"
[ngClass]="active">Warnings</span>
</div>
<div [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>
@@ -1,109 +0,0 @@
<ng-container>
<app-header [current_page]="'bans'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">>
<div class="title" header-content>
<h1>Minecraft Punishments</h1>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<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>
@@ -1,53 +0,0 @@
<ng-container *ngIf="history.length === 0">
<p>No history found</p>
</ng-container>
<ng-container *ngIf="history.length > 0">
<table [cellSpacing]="0">
<div class="historyTableHead">
<thead>
<tr>
<th class="historyType">Type</th>
<th class="historyPlayer">Player</th>
<th class="historyPlayer">Banned By</th>
<th class="historyReason">Reason</th>
<th class="historyDate">Date</th>
<th class="historyDate">Expires</th>
</tr>
</thead>
</div>
<div>
<tbody>
<tr 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>
@@ -1,5 +0,0 @@
<app-forms [currentPage]="'appeal'" [formTitle]="'Minecraft Appeal'">
<div form-content>
</div>
</app-forms>
@@ -1,69 +0,0 @@
import {Component, OnInit} from '@angular/core';
import {FormsComponent} from '../forms.component';
import {FormControl, FormGroup, Validators} from '@angular/forms';
import {AppealsService, MinecraftAppeal} from '../../../api';
@Component({
selector: 'app-appeal',
imports: [
FormsComponent
],
templateUrl: './appeal.component.html',
styleUrl: './appeal.component.scss'
})
export class AppealComponent implements OnInit {
public form: FormGroup<Appeal>;
constructor(private appealApi: AppealsService) {
this.form = new FormGroup({
username: new FormControl('', {nonNullable: true, validators: [Validators.required]}),
punishmentId: new FormControl('', {nonNullable: true, validators: [Validators.required]}),
email: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.email]}),
appeal: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.minLength(10)]})
});
}
ngOnInit() {
}
public onSubmit() {
if (this.form === undefined) {
console.error('Form is undefined');
return
}
if (this.form.valid) {
this.sendForm()
} else {
// Mark all fields as touched to trigger validation display
Object.keys(this.form.controls).forEach(field => {
const control = this.form!.get(field);
if (!(control instanceof FormGroup)) {
console.error('Control [' + control + '] is not a FormGroup');
return;
}
control.markAsTouched({onlySelf: true});
});
}
}
private sendForm() {
const rawValue = this.form.getRawValue();
const appeal: MinecraftAppeal = {
appeal: rawValue.appeal,
email: rawValue.email,
punishmentId: parseInt(rawValue.punishmentId),
username: rawValue.username,
uuid: ''//TODO
}
this.appealApi.submitMinecraftAppeal(appeal).subscribe()
}
}
interface Appeal {
username: FormControl<string>;
punishmentId: FormControl<string>;
email: FormControl<string>;
appeal: FormControl<string>;
}
@@ -1,18 +0,0 @@
<ng-container>
<app-header [current_page]="currentPage" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>{{ formTitle }}</h1>
</div>
</app-header>
<ng-container *ngIf="!type">
<ng-container *ngFor="let formType of FormType | keyvalue">
<button mat-raised-button (click)="setFormType(formType.value)">
{{ formType }}
</button>
</ng-container>
</ng-container>
<div>
<ng-content select="[form-content]"></ng-content>
</div>
</ng-container>
-68
View File
@@ -1,68 +0,0 @@
import {Component, Input, OnInit} from '@angular/core';
import {HeaderComponent} from '../header/header.component';
import {MatDialog} from '@angular/material/dialog';
import {ActivatedRoute} from '@angular/router';
import {LoginDialogComponent} from '../login/login.component';
import {KeyValuePipe, NgForOf, NgIf} from '@angular/common';
import {FormType} from './form_type';
import {MatButton} from '@angular/material/button';
import {AuthService} from '../services/auth.service';
@Component({
selector: 'app-forms',
imports: [
HeaderComponent,
NgIf,
NgForOf,
MatButton,
KeyValuePipe
],
templateUrl: './forms.component.html',
styleUrl: './forms.component.scss'
})
export class FormsComponent implements OnInit {
@Input() formTitle: string = 'Form';
@Input() currentPage: string = 'forms';
public type: FormType | undefined;
constructor(private authService: AuthService,
private dialog: MatDialog,
private route: ActivatedRoute,
) {
this.route.paramMap.subscribe(async params => {
const code = params.get('code');
if (code) {
this.authService.login(code).subscribe();
} else if (!this.authService.checkAuthStatus()) {
const dialogRef = this.dialog.open(LoginDialogComponent, {
width: '400px',
disableClose: true
});
dialogRef.afterClosed().subscribe();
}
});
}
ngOnInit() {
this.route.paramMap.subscribe(params => {
switch (params.get('form')) {
case FormType.APPEAL:
this.type = FormType.APPEAL;
this.currentPage = 'appeal';
break;
default:
throw new Error("Invalid type");
}
});
}
protected readonly FormType = FormType;
protected readonly Object = Object;
public setFormType(formType: FormType) {
this.type = formType;
}
}
+44
View File
@@ -0,0 +1,44 @@
import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
import {Observable} from 'rxjs';
import {AuthService} from '@services/auth.service';
import {environment} from '@environment';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
) {
}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
if (environment.defaultAuthStatus) {
return true;
}
if (!this.authService.checkAuthStatus()) {
return this.router.createUrlTree(['/']);
}
const requiredAuthorizations = route.data['requiredAuthorizations'] as string[];
if (!requiredAuthorizations || requiredAuthorizations.length === 0) {
return true;
}
const userAuthorizations = this.authService.getUserAuthorizations();
const hasAccess = requiredAuthorizations.some(auth => userAuthorizations.includes(auth));
if (!hasAccess) {
return this.router.createUrlTree(['/']);
}
return true;
}
}
-157
View File
@@ -1,157 +0,0 @@
<ng-container>
<app-header [current_page]="'home'" height="100vh"
[background_image]="'/public/img/backgrounds/120spawn-min.png'">
<div class="title" header-content>
<h1 style="display: none;">Altitude</h1>
<img id="header-img" ngSrc="/public/img/logos/logo.png" alt="The Altitude Minecraft Server" height="319"
width="550">
<h2 style="font-size: 2.5em;" id="homeh2">Altitude now on {{ ALTITUDE_VERSION }}!</h2>
<a id="scroll-button" (click)="scrollToSection()">
<span></span>
<p style="display: none;">Scroll Down</p>
</a>
</div>
</app-header>
<main>
<section id="scrollingpoint" style="background: #202020; text-align: center; padding: 80px 0;">
<!-- TODO load player count from old api or backend?-->
<h2 style="color: white;"><span class="player-count">Loading...</span></h2>
<h2 style="color: white;">Server IP: play.alttd.com</h2>
<div style="padding-top: 35px;">
<app-copy-ip></app-copy-ip>
</div>
</section>
<section class="darkmodeSection">
<div class="container">
<div class="paragraph">
<h2>Adventure Begins</h2>
<p>You awake in a strange town, where are you? There are residents running about trading with each other and
stories of distant realms with more towns. It's time to write your story. Welcome to Altitude, the laid-back
community-oriented server that hosts your home for Minecraft.</p>
</div>
<img ngSrc="/public/img/items/bookquill.png" style="width: 150px; align-self: center; margin: 0 auto;"
alt="Alternative Altitude Server Logo"
height="150" width="150">
</div>
</section>
<!--
<section id="section1">
<div class="container" id="video">
<h2 style="display: none;">YouTube Trailer</h2>
<div style="border-radius:5px;overflow:hidden;position:relative;width:100%">
<img style="width: 100%" src="https://img.youtube.com/vi/Nzbj9Dbv5Wk/maxresdefault.jpg" alt="Altitude YouTube Trailer">
<div id="youtube">
<div class="play" onclick="playVideo()"></div>
</div>
</div>
</div>
</section>
-->
<section class="darkmodeSection">
<div class="container" style="padding: 10px 0 80px 0">
<iframe id="discord-widget" src="https://discordapp.com/widget?id=141644560005595136&theme=dark"></iframe>
<div class="paragraph" id="discordp">
<h2>Meet the Community</h2>
<p>Altitude is home to players young and old from all around the globe, and here, everyone is family. Altitude
is your place to get together with friends and relax - and maybe enjoy some survival too. Altitude is
intended for older players, but all are welcome!</p>
<p>Don't have Discord? Keep up with news and announcements at the <a href="alttd.com/blog">blog</a>.</p>
<div style="display: flex; justify-content: center;">
<a target="_blank" rel="noopener" href="https://discordapp.com/invite/TGqpzCJ">
<div style="margin-top: 30px;" class="button-outer">
<span class="button-inner">Join Our Discord</span>
</div>
</a>
</div>
</div>
</div>
</section>
<section id="section2">
<div class="customContainer">
<h2 style="color: white; padding-bottom: 35px; font-size: 2.8em;">Survival Shaped by You</h2>
<p style="color: white; padding-bottom: 35px; font-size: 1.1em; margin: auto;">Altitude is built by the
community, for the community. We've added features requested by our members and several custom plugins to
create our "perfect" survival experience.</p>
<div class="survivalShapedContainerPlugins">
<div class="pluginColumn">
<h2>McMMO & MyPet</h2>
<p>Two of the most requested plugins on Altitude, level up yourself and your pet with these MMO-based
plugins!</p>
<a [routerLink]="['/mypet']">
<div class="button-outer">
<span class="button-inner">MyPet Info</span>
</div>
</a>
</div>
<div class="pluginColumn">
<h2>Dynamic map</h2>
<p>See the world and the players around it in real-time! The map shows the entire survival world with claims
and warps.</p>
<a [routerLink]="['/map']">
<div class="button-outer">
<span class="button-inner">Visit Map</span>
</div>
</a>
</div>
<div class="pluginColumn">
<h2>Player Shops</h2>
<p>Our economy is built on player shops, encouraging player interaction and putting the control in your
hands.</p>
<a [routerLink]="['/economy']">
<div class="button-outer">
<span class="button-inner">Shop Guide</span>
</div>
</a>
</div>
</div>
</div>
</section>
<section class="removemobile darkmodeSection">
<div class="customContainer">
<h2 style="padding-bottom: 35px; font-size: 2.8em;">Community Builds</h2>
<p style="padding-bottom: 35px; font-size: 1.1em; margin: auto;">Thank you to our brilliant players for sharing
their impressive builds. Take a look at some of them here!<br>
If you know of any builds that should be featured, please let us know!</p>
<div class="sliderWrapper">
<div class="sliderContent">
<div class="indexSlider">
<div id="go-left" (click)="previousSlide()" class="circleBehind goLeft"></div>
<div id="go-right" (click)="nextSlide()" class="circleBehind goRight"></div>
<div class="display">
<span class="slide"
[style.background-image]="'url(' + slide + ')'"
[style.opacity]="carouselOpacity"
[style.display]="'block'"></span>
</div>
</div>
<div class="dots">
<ng-container *ngFor="let slide of getSlideIndices();">
<span class="dot" (click)="setSlide(slide)" [ngClass]="getDotClass(slide)"></span>
</ng-container>
</div>
</div>
</div>
</div>
</section>
<section style="background: #202020;">
<div class="customContainer">
<h2 id="quote" style="color: white; font-family: 'minecraft-text',sans-serif; line-height: 1.3em;">"Great
community, great people, great server all round . . . If it can bring back my love for minecraft, it could
work wonders for you."</h2>
<p style="color: white; margin-top:30px;">- /u/seanhanley1993</p>
</div>
</section>
<section class="darkmodeSection">
<div class="customContainer">
<img ngSrc="/public/img/logos/log.png" alt="Alternative Altitude Server Logo" height="129"
width="120">
<h2 style="margin: 20px 0; font-size: 1.8em; padding-bottom: 0 !important;">play.alttd.com</h2>
<app-copy-ip></app-copy-ip>
</div>
</section>
<a (click)="this.scrollService.scrollToTop()" class="scroll-up-button, active">
<span></span>
<p style="display: none;">Scroll Down</p>
</a>
</main>
</ng-container>
@@ -1,18 +0,0 @@
<h2 mat-dialog-title>Login</h2>
<div mat-dialog-content>
<form [formGroup]="loginForm">
<mat-form-field appearance="fill" style="width: 100%">
<mat-label>Enter your code</mat-label>
<input matInput formControlName="code" type="text">
<mat-error *ngIf="formHasError()">
Code is required
</mat-error>
</mat-form-field>
</form>
</div>
<div mat-dialog-actions align="end">
<button mat-button (click)="onCancel()">Cancel</button>
<button mat-flat-button color="primary" (click)="onSubmit()" [disabled]="!loginForm.valid">
Submit
</button>
</div>
@@ -0,0 +1,18 @@
import {Component} from '@angular/core';
import {ScrollService} from '@services/scroll.service';
import {HeaderComponent} from '@header/header.component';
@Component({
selector: 'app-about',
standalone: true,
imports: [
HeaderComponent
],
templateUrl: './about.component.html',
styleUrl: './about.component.scss'
})
export class AboutComponent {
constructor(public scrollService: ScrollService) {
}
}
@@ -1,15 +1,14 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service'; import {ScrollService} from '@services/scroll.service';
import {CommonModule} from '@angular/common';
import {HeaderComponent} from '../header/header.component'; import {HeaderComponent} from '@header/header.component';
@Component({ @Component({
selector: 'app-birthdays', selector: 'app-birthdays',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule,
HeaderComponent HeaderComponent
], ],
templateUrl: './birthdays.component.html', templateUrl: './birthdays.component.html',
styleUrl: './birthdays.component.scss' styleUrl: './birthdays.component.scss'
}) })
@@ -1,5 +1,5 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {HeaderComponent} from "../header/header.component"; import {HeaderComponent} from "@header/header.component";
@Component({ @Component({
selector: 'app-community', selector: 'app-community',
@@ -1,16 +1,15 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service'; import {ScrollService} from '@services/scroll.service';
import {CommonModule, NgOptimizedImage} from '@angular/common'; import { NgOptimizedImage } from '@angular/common';
import {HeaderComponent} from '../header/header.component'; import {HeaderComponent} from '@header/header.component';
@Component({ @Component({
selector: 'app-socials', selector: 'app-socials',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule,
HeaderComponent, HeaderComponent,
NgOptimizedImage NgOptimizedImage
], ],
templateUrl: './socials.component.html', templateUrl: './socials.component.html',
styleUrl: './socials.component.scss' styleUrl: './socials.component.scss'
}) })
@@ -0,0 +1,86 @@
<ng-container>
<app-header [current_page]="'team'" height="450px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">>
<div class="title" header-content>
<h1>Staffing Team</h1>
<h2>The team that makes Altitude happen. Your owners, admins, moderators, and trainees are all working together to
create Altitude, to create home. This is where the magic happens.</h2>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<div class="container teamContainer">
<h2 class="sectionTitle">Management</h2>
@for (member of getTeamMembers('owner') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Owner</p>
</div>
}
@for (member of getTeamMembers('manager') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Manager</p>
</div>
}
</div>
</section>
<section class="darkmodeSectionThree">
<div class="container teamContainer">
<h2 class="sectionTitle">Admins</h2>
@for (member of getTeamMembers('admin') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Admin</p>
</div>
}
</div>
</section>
<section class="darkmodeSection">
<div class="container teamContainer">
<h2 class="sectionTitle">Head Moderators</h2>
@for (member of getTeamMembers('headmod') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Head Mod</p>
</div>
}
</div>
</section>
<section class="darkmodeSectionThree">
<div class="container teamContainer">
<h2 class="sectionTitle">Moderators</h2>
@for (member of getTeamMembers('moderator') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
</div>
}
</div>
</section>
@if ((getTeamMembers('trainee') | async)?.length ?? 0 > 0) {
<section class="darkmodeSection">
<div class="container teamContainer">
<h2 class="sectionTitle">Trainees</h2>
@for (member of getTeamMembers('trainee') | async; track member) {
<div class="member">
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
</div>
}
</div>
</section>
}
</main>
</ng-container>
@@ -1,11 +1,11 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {ScrollService} from '../scroll/scroll.service'; import {ScrollService} from '@services/scroll.service';
import {BASE_PATH, Player, TeamService} from '../../api'; import {BASE_PATH, Player, TeamService} from '@api';
import {CommonModule, NgOptimizedImage} from '@angular/common'; import { CommonModule, NgOptimizedImage } from '@angular/common';
import {HeaderComponent} from '../header/header.component'; import {HeaderComponent} from '@header/header.component';
import {CookieService} from 'ngx-cookie-service'; import {CookieService} from 'ngx-cookie-service';
import {map, Observable, shareReplay} from 'rxjs'; import {map, Observable, shareReplay} from 'rxjs';
import {environment} from '../../environments/environment'; import {environment} from '@environment';
@Component({ @Component({
selector: 'app-team', selector: 'app-team',
@@ -19,8 +19,8 @@
<p>To claim land, you will need some basic tools: a golden shovel, and a stick. You can craft these items <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 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> stick is used for viewing claim information.</p>
<img ngSrc="/public/img/items/shovel.png" alt="Minecraft golden shovel" style="width: 25%;" height="114" <img ngSrc="/public/img/items/shovel.png" alt="Minecraft golden shovel"
width="114"> class="shovelClaiming" style="width: 25%;" height="114" width="114">
<p>Players start with an allowance of 500 “claim blocks” and can purchase additional “claim blocks” with <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 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 many claims you can create depends on the rank you have - the amount can be found on the <a
@@ -7,3 +7,10 @@ main li {
margin-left: 30px; margin-left: 30px;
padding-bottom: 10px; padding-bottom: 10px;
} }
@media (max-width: 690px) {
.shovelClaiming {
height: 100px;
}
}
@@ -1,5 +1,5 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {HeaderComponent} from '../header/header.component'; import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common'; import {NgOptimizedImage} from '@angular/common';
import {RouterLink} from '@angular/router'; import {RouterLink} from '@angular/router';
@@ -163,14 +163,13 @@
<div class="columnParagraph"> <div class="columnParagraph">
<h2>Miscellaneous</h2> <h2>Miscellaneous</h2>
<p>Grindstones can strip shulker box & beehive NBT data</p> <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>/iwanttobreakthisblock, required to break some natural generated blocks</p>
<p>/sneakclickmending, allows you to mend your items by right clicking while sneaking</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>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>Nether portals can be as small as 1x2 (1x2 portal, 3x4 base)</p>
<p>Lootchests refill once for every player</p> <p>Lootchests refill once for every player</p>
<p>Empty maps can be duplicated using paper in a cartography table</p> <p>Empty maps can be duplicated using paper in a cartography table</p>
<p>Eating glowberries gives you a glowing effect</p> <p>Eating glow berries gives you a glowing effect</p>
</div> </div>
</div> </div>
</section> </section>
@@ -7,3 +7,9 @@ main li {
margin-left: 30px; margin-left: 30px;
padding-bottom: 10px; padding-bottom: 10px;
} }
@media (max-width: 690px) {
.columnContainer {
text-align: left !important;
}
}
@@ -1,5 +1,5 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {HeaderComponent} from "../header/header.component"; import {HeaderComponent} from "@header/header.component";
import {RouterLink} from '@angular/router'; import {RouterLink} from '@angular/router';
@Component({ @Component({
@@ -104,7 +104,7 @@
prices will become more and more extreme. You can see where these thresholds are based on your current prices will become more and more extreme. You can see where these thresholds are based on your current
points here:</p> points here:</p>
<img ngSrc="/public/img/random/pointbracket.png" alt="Visualization of the point bracket." <img ngSrc="/public/img/random/pointbracket.png" alt="Visualization of the point bracket."
style="width: 100%; padding: 0 0 15px 0;" class="visEconomy" style="width: 100%; padding: 0 0 15px 0;"
height="100" width="800"> height="100" width="800">
</div> </div>
<div class="columnParagraph"> <div class="columnParagraph">
@@ -0,0 +1,25 @@
main ul {
font-family: opensans, sans-serif;
text-align: left;
}
main li {
margin-left: 30px;
padding-bottom: 10px;
}
@media (max-width: 690px) {
.title {
height: calc(100% - 110px);
margin-top: 100px;
}
.title h2 {
font-size: 0.9em;
}
.visEconomy {
height: 50px;
width: 357px;
}
}
@@ -1,5 +1,5 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {HeaderComponent} from '../header/header.component'; import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common'; import {NgOptimizedImage} from '@angular/common';
@Component({ @Component({
@@ -11,3 +11,13 @@
padding-bottom: 25px; padding-bottom: 25px;
font-weight: 100; font-weight: 100;
} }
@media (max-width: 670px) {
.title h1 {
font-size: 2em;
}
.title h2 {
font-size: 1em;
}
}
@@ -1,13 +1,12 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {CommonModule} from '@angular/common';
import {HeaderComponent} from '../header/header.component'; import {HeaderComponent} from '@header/header.component';
@Component({ @Component({
standalone: true, standalone: true,
imports: [ imports: [
CommonModule,
HeaderComponent HeaderComponent
], ],
selector: 'app-map', selector: 'app-map',
templateUrl: './map.component.html', templateUrl: './map.component.html',
styleUrl: './map.component.scss' styleUrl: './map.component.scss'
@@ -24,8 +24,8 @@
your other pets while they are in storage. To interact with your MyPet you will need to make it your your other pets while they are in storage. To interact with your MyPet you will need to make it your
active MyPet by doing <span style="font-family: 'opensans-bold', sans-serif;">/petswitch</span> and active MyPet by doing <span style="font-family: 'opensans-bold', sans-serif;">/petswitch</span> and
selecting the one you want to use.</p> selecting the one you want to use.</p>
<img ngSrc="/public/img/items/lead.png" alt="Minecraft lead/leash" style="width: 20%;" height="96" <img ngSrc="/public/img/items/lead.png" alt="Minecraft lead/leash"
width="96"> class="leadMyPet" style="width: 20%;" height="96" width="96">
</div> </div>
<div class="columnParagraph"> <div class="columnParagraph">
<h2>Skilltrees and Levels</h2> <h2>Skilltrees and Levels</h2>
@@ -13,3 +13,9 @@ main li {
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
} }
@media (max-width: 690px) {
.leadMyPet {
height: 70px;
}
}
@@ -1,5 +1,5 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {HeaderComponent} from '../header/header.component'; import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common'; import {NgOptimizedImage} from '@angular/common';
@Component({ @Component({
@@ -1,5 +1,5 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {HeaderComponent} from '../header/header.component'; import {HeaderComponent} from '@header/header.component';
@Component({ @Component({
selector: 'app-skyblock', selector: 'app-skyblock',
@@ -19,10 +19,8 @@
name, description, and icon) on their own! This lets you easily promote your town, shop, farm, or just name, description, and icon) on their own! This lets you easily promote your town, shop, farm, or just
about anything else.</p> about anything else.</p>
<p>All warps are separated into categories so it's easy to find the category of warp you're looking for.</p> <p>All warps are separated into categories so it's easy to find the category of warp you're looking for.</p>
<img ngSrc="/public/img/random/warpgui.png" <img ngSrc="/public/img/random/warpgui.png" alt="In-game warp GUI"
alt="In-game warp GUI" class="guiWarp" style="width: 80%; padding-bottom: 15px;" height="232" width="356">
style="width: 80%; padding-bottom: 15px;"
height="232" width="356">
</div> </div>
</div> </div>
<div class="columnContainer"> <div class="columnContainer">
@@ -45,7 +43,7 @@
</div> </div>
</section> </section>
<section class="columnSection" style="padding-top: 0;"> <section class="columnSection" style="padding-top: 0;">
<div class="columnParagraph" style="padding-left: 15px;"> <div class="columnParagraph" style="padding-left: 15px; text-align: center;">
<h2>Warp Requirements</h2> <h2>Warp Requirements</h2>
<p>You need to be the owner of the claim your warp is placed in. It should look good, and be as finished as <p>You need to be the owner of the claim your warp is placed in. It should look good, and be as finished as
your warp type allows you to have it. Safety is an important aspect as well, visitors should not be your warp type allows you to have it. Safety is an important aspect as well, visitors should not be
@@ -207,10 +205,8 @@
warp, just do <span style="font-family: 'opensans-bold', sans-serif;">/warps</span> and click on the chest warp, just do <span style="font-family: 'opensans-bold', sans-serif;">/warps</span> and click on the chest
labeled "<span style="font-family: 'opensans-bold', sans-serif;">My Warps</span>" in the bottom left labeled "<span style="font-family: 'opensans-bold', sans-serif;">My Warps</span>" in the bottom left
corner.</p> corner.</p>
<img ngSrc="/public/img/random/editwarpgui.png" <img ngSrc="/public/img/random/editwarpgui.png" alt="In-game warp edit GUI"
alt="In-game warp edit GUI" class="editWarp" style="width: 80%;" width="384" height="170">
style="width: 80%;"
width="384" height="170">
<p>Maintaining a warp also involves making sure it looks nice, shops are well stocked, and, if it's a town, <p>Maintaining a warp also involves making sure it looks nice, shops are well stocked, and, if it's a town,
open plots are always available for residents to move in. Make sure you keep up on maintaining your warp open plots are always available for residents to move in. Make sure you keep up on maintaining your warp
or it could be deleted! If a warp is deleted by a staff member you will not receive a refund for the or it could be deleted! If a warp is deleted by a staff member you will not receive a refund for the
@@ -12,3 +12,13 @@ main li {
color: var(--font-color); color: var(--font-color);
transition: 0.5s ease; transition: 0.5s ease;
} }
@media (max-width: 690px) {
.guiWarp {
height: 190px;
}
.editWarp {
height: 130px;
}
}
@@ -1,5 +1,5 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {HeaderComponent} from '../header/header.component'; import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common'; import {NgOptimizedImage} from '@angular/common';
import {RouterLink} from '@angular/router'; import {RouterLink} from '@angular/router';
@@ -3,12 +3,12 @@
<div class="footerInner"> <div class="footerInner">
<div class="footerText"> <div class="footerText">
<h2>ABOUT US</h2> <h2>ABOUT US</h2>
<p>Altitude is a community-centered {{ ALTITUDE_VERSION }} survival server. We're one of those servers you come <p>Altitude is a community-centered {{ ALTITUDE_VERSION }} survival server. We're one of those servers you come
to call "home". We are your place to get together with friends and play survival, with a few extra features to call "home". We are your place to get together with friends and play survival, with a few extra features
suggested by our community!</p> suggested by our community!</p>
<div class="followUs" style="height: 35px; display: flex; align-items: flex-end;"> <div class="followUs" style="height: 35px; display: flex; align-items: flex-end;">
<a target="_blank" rel="noopener" href="https://discordapp.com/invite/TGqpzCJ"> <a target="_blank" rel="noopener" href="https://discordapp.com/invite/TGqpzCJ">
<img ngSrc="/public/img/logos/discord.png" alt="Discord Button" height="32" width="32"> <img priority ngSrc="/public/img/logos/discord.png" alt="Discord Button" height="32" width="32">
</a> </a>
<a target="_blank" rel="noopener" href="https://twitter.com/alttdmc"> <a target="_blank" rel="noopener" href="https://twitter.com/alttdmc">
<img ngSrc="/public/img/logos/twitter.png" alt="Twitter Button" height="32" width="32"> <img ngSrc="/public/img/logos/twitter.png" alt="Twitter Button" height="32" width="32">
@@ -67,3 +67,40 @@ footer ul, footer p {
.copyright { .copyright {
margin-top: 50px; margin-top: 50px;
} }
@media (max-width: 1150px) {
.footerInner {
flex-wrap: wrap;
text-align: center;
}
.footerText {
flex: 1 1 100%;
margin-right: 0;
margin-bottom: 30px;
}
.footerNav {
border-left: none;
padding-left: 0px;
padding-bottom: 15px;
min-width: 200px;
}
.followUs {
width: 100px;
margin: 0 auto;
}
.footerNav {
border-left: none;
padding-left: 0px;
padding-bottom: 15px;
min-width: 200px;
}
.copyright {
margin-top: 30px;
text-align: center;
}
}
@@ -1,16 +1,15 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {ALTITUDE_VERSION} from '../constant'; import {ALTITUDE_VERSION} from '@custom-types/constant';
import {CommonModule, NgOptimizedImage} from '@angular/common'; import { NgOptimizedImage } from '@angular/common';
import {RouterLink} from '@angular/router'; import {RouterLink} from '@angular/router';
@Component({ @Component({
selector: 'app-footer', selector: 'app-footer',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule,
RouterLink, RouterLink,
NgOptimizedImage NgOptimizedImage
], ],
templateUrl: './footer.component.html', templateUrl: './footer.component.html',
styleUrl: './footer.component.scss' styleUrl: './footer.component.scss'
}) })

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