Compare commits
160
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bfcdb6ccc | ||
|
|
64ea68ab39 | ||
|
|
f117cb2477 | ||
|
|
d84d0c7fef | ||
|
|
00bf7caec2 | ||
|
|
41dab473b0 | ||
|
|
b71ea7da8b | ||
|
|
a55806e5dd | ||
|
|
7e25cc583c | ||
|
|
894dfac0c6 | ||
|
|
300d33da7d | ||
|
|
6f6801c728 | ||
|
|
f8157e997a | ||
|
|
74e8697fef | ||
|
|
8ad87da47e | ||
|
|
29a28e712e | ||
|
|
6ad3b5221a | ||
|
|
1b697fcaa3 | ||
|
|
ed9d41cdc6 | ||
|
|
5eaeb3552a | ||
|
|
8e9e267fb0 | ||
|
|
6d8f73201f | ||
|
|
91e5a2a9a0 | ||
|
|
0005b3b6d4 | ||
|
|
b3999b3389 | ||
|
|
5a4df2572d | ||
|
|
e697f7ca90 | ||
|
|
3da46c203b | ||
|
|
e3fd0944df | ||
|
|
cd34cd93ad | ||
|
|
a9294d1115 | ||
|
|
745dab4d80 | ||
|
|
dc65b19a8f | ||
|
|
01dab905d4 | ||
|
|
311d77fcb2 | ||
|
|
cdbf862ecf | ||
|
|
643b15f2e0 | ||
|
|
f886609a0e | ||
|
|
2a0f38aa28 | ||
|
|
4878ad9f0d | ||
|
|
80cb2d0ad1 | ||
|
|
53f67c0b67 | ||
|
|
1f1f1793e3 | ||
|
|
4962d16abd | ||
|
|
cad574b8fb | ||
|
|
c75f0cdb15 | ||
|
|
fe545972e3 | ||
|
|
eab1c9322b | ||
|
|
ffddffa8dc | ||
|
|
0b4c1ccebf | ||
|
|
2e89fcec66 | ||
|
|
42b11eecf1 | ||
|
|
d1da1296bb | ||
|
|
523bf3d43f | ||
|
|
4ccce7e190 | ||
|
|
641083732d | ||
|
|
da17cf9696 | ||
|
|
de1876c90c | ||
|
|
c3f3b20807 | ||
|
|
37fb49cda1 | ||
|
|
db642103ed | ||
|
|
f026f24263 | ||
|
|
eaee31ab2b | ||
|
|
24d7cfe913 | ||
|
|
770a2e0d14 | ||
|
|
101794d8f2 | ||
|
|
eb72ce14cc | ||
|
|
d1ba89acc8 | ||
|
|
d28b4a2b62 | ||
|
|
56f4ccf40e | ||
|
|
d73f057596 | ||
|
|
e825d83124 | ||
|
|
238c5d9644 | ||
|
|
4222df87a3 | ||
|
|
16cc57d774 | ||
|
|
c536bfbf30 | ||
|
|
f67cb50f41 | ||
|
|
bdb38e5011 | ||
|
|
ae1e972438 | ||
|
|
737b26a6c7 | ||
|
|
5013b9a204 | ||
|
|
fcb64db137 | ||
|
|
d2e064e2b4 | ||
|
|
f50f2dc6c2 | ||
|
|
c277306c2c | ||
|
|
1f03a4bdc3 | ||
|
|
7f1c59d102 | ||
|
|
f968a64dd4 | ||
|
|
c25364caf7 | ||
|
|
15c3cc7f26 | ||
|
|
2b96957876 | ||
|
|
b16fab26e7 | ||
|
|
28fd05a656 | ||
|
|
ff1b09be92 | ||
|
|
8a839ac922 | ||
|
|
3f76a98409 | ||
|
|
871615702b | ||
|
|
291c9df5c6 | ||
|
|
4150324d75 | ||
|
|
4267c782a7 | ||
|
|
343964eda8 | ||
|
|
1ce2088cae | ||
|
|
0b952e07f7 | ||
|
|
c2b9a8a574 | ||
|
|
d3ef296784 | ||
|
|
5a792463cc | ||
|
|
974d50d7cd | ||
|
|
62f837914c | ||
|
|
ace969ba3b | ||
|
|
2fc6ba53f6 | ||
|
|
4c38b070ea | ||
|
|
db394beda6 | ||
|
|
76cb3cd89c | ||
|
|
5d8ab2deef | ||
|
|
aef32a8982 | ||
|
|
42f0961f13 | ||
|
|
04310e1cce | ||
|
|
54e747118c | ||
|
|
cce83a08de | ||
|
|
f0faa63ca7 | ||
|
|
dfea91d8ca | ||
|
|
73916f0aae | ||
|
|
ebe66c87c0 | ||
|
|
c42fc38b2c | ||
|
|
213f9987d9 | ||
|
|
48cac607de | ||
|
|
6ed2e15017 | ||
|
|
7fc25f46f3 | ||
|
|
c72703ea32 | ||
|
|
e837a9216d | ||
|
|
d4363b3a8a | ||
|
|
1e5862bae6 | ||
|
|
daf88ea437 | ||
|
|
9abd570b87 | ||
|
|
5284d498f3 | ||
|
|
c3a7be82e9 | ||
|
|
fdb57289f8 | ||
|
|
60c1329163 | ||
|
|
0e71c0f581 | ||
|
|
eb67a33331 | ||
|
|
39f20796ce | ||
|
|
e00165c56f | ||
|
|
02c6497700 | ||
|
|
0efd476676 | ||
|
|
237518638c | ||
|
|
fea1a98cea | ||
|
|
ecd9b3d824 | ||
|
|
9808b5d63d | ||
|
|
c13b7077a7 | ||
|
|
023ae809ef | ||
|
|
3a6f137c9a | ||
|
|
cb8447a096 | ||
|
|
3e98e1a498 | ||
|
|
4c31a91bb4 | ||
|
|
d6faaba01c | ||
|
|
56175e62d6 | ||
|
|
283838f444 | ||
|
|
49fe335c73 | ||
|
|
1875f050c6 | ||
|
|
52d8658be3 |
@@ -27,6 +27,7 @@ dependencies {
|
||||
implementation(project(":open_api"))
|
||||
implementation(project(":database"))
|
||||
implementation(project(":frontend"))
|
||||
implementation(project(":discord"))
|
||||
annotationProcessor("org.projectlombok:lombok")
|
||||
implementation("com.mysql:mysql-connector-j:8.0.32")
|
||||
implementation("org.mybatis:mybatis:3.5.13")
|
||||
@@ -36,6 +37,12 @@ dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.security:spring-security-oauth2-resource-server")
|
||||
implementation("org.springframework.security:spring-security-oauth2-jose")
|
||||
implementation("org.springframework.boot:spring-boot-starter-mail:3.1.5")
|
||||
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
||||
|
||||
//Open API
|
||||
implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
|
||||
implementation("io.swagger.core.v3:swagger-models:2.2.37")
|
||||
|
||||
//AOP
|
||||
implementation("org.aspectj:aspectjrt:1.9.19")
|
||||
|
||||
@@ -5,7 +5,7 @@ import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.EnableAspectJAutoProxy;
|
||||
|
||||
@SpringBootApplication
|
||||
@SpringBootApplication(scanBasePackages = {"com.alttd.altitudeweb"})
|
||||
@EnableAspectJAutoProxy
|
||||
public class AltitudeWebApplication {
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -9,41 +9,71 @@ import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final KeyPairService keyPairService;
|
||||
private final SecurityAuthFailureHandler securityAuthFailureHandler;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/login/userLogin/**", "/login/requestNewUserLogin/**").permitAll()
|
||||
.requestMatchers("/team/**", "/history/**").permitAll()
|
||||
.requestMatchers("/form/**").hasAuthority(PermissionClaimDto.USER.getValue())
|
||||
.requestMatchers("/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.anyRequest().authenticated()
|
||||
.authorizeHttpRequests(
|
||||
auth -> auth
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/api/form/**").authenticated()
|
||||
.requestMatchers("/api/login/getUsername").authenticated()
|
||||
.requestMatchers("/api/mail/**").authenticated()
|
||||
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.requestMatchers("/api/login/userLogin/**").permitAll()
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.oauth2ResourceServer(
|
||||
oauth2 -> oauth2
|
||||
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
|
||||
.authenticationEntryPoint(securityAuthFailureHandler)
|
||||
.accessDeniedHandler(securityAuthFailureHandler)
|
||||
)
|
||||
.exceptionHandling(
|
||||
ex -> ex
|
||||
.authenticationEntryPoint(securityAuthFailureHandler)
|
||||
.accessDeniedHandler(securityAuthFailureHandler)
|
||||
)
|
||||
.sessionManagement(
|
||||
session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -62,4 +92,46 @@ public class SecurityConfig {
|
||||
KeyPair keyPair = keyPairService.getJwtSigningKeyPair();
|
||||
return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Bean
|
||||
public JwtAuthenticationConverter jwtAuthenticationConverter() {
|
||||
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
||||
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
|
||||
Map<String, Object> claims = jwt.getClaims();
|
||||
|
||||
Object authoritiesClaim = claims.get("authorities");
|
||||
if (authoritiesClaim instanceof List<?> authorities) {
|
||||
Collection<GrantedAuthority> authorityList = authorities.stream()
|
||||
.map(Object::toString)
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.collect(Collectors.toList());
|
||||
log.debug("Authorities found in authorities: {}", authorityList);
|
||||
return authorityList;
|
||||
}
|
||||
|
||||
Object scopeClaim = claims.get("scope");
|
||||
if (scopeClaim instanceof String scopeString) {
|
||||
Collection<GrantedAuthority> authorityList = Arrays.stream(scopeString.split(" "))
|
||||
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
|
||||
.collect(Collectors.toList());
|
||||
log.debug("Authorities found in authorities scope string: {}", authorityList);
|
||||
return authorityList;
|
||||
}
|
||||
|
||||
if (scopeClaim instanceof List<?> scopeList) {
|
||||
Collection<GrantedAuthority> authorityList = scopeList.stream()
|
||||
.map(Object::toString)
|
||||
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
|
||||
.collect(Collectors.toList());
|
||||
log.debug("Authorities found in authorities scope list: {}", authorityList);
|
||||
return authorityList;
|
||||
}
|
||||
|
||||
log.debug("No granted authorities found");
|
||||
return Collections.emptyList();
|
||||
});
|
||||
return converter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
package com.alttd.altitudeweb.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Configuration
|
||||
@Slf4j @Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.addResourceLocations("classpath:/static/browser")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
@@ -23,11 +26,23 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
Resource requestedResource = location.createRelative(resourcePath);
|
||||
|
||||
if (requestedResource.exists() && requestedResource.isReadable()) {
|
||||
log.debug("Serving resource {} from {}", resourcePath, location);
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public class CorsConfig implements WebMvcConfigurer {
|
||||
log.info("Registering CORS mappings for {}", String.join(", ", allowedOrigins));
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
|
||||
-36
@@ -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");
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.alttd.altitudeweb.controllers.data_from_auth;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class AuthenticatedUuid {
|
||||
@Value("${UNSECURED:#{false}}")
|
||||
private boolean unsecured;
|
||||
|
||||
/**
|
||||
* Extracts and validates the authenticated user's UUID from the JWT token.
|
||||
*
|
||||
* @return The UUID of the authenticated user
|
||||
* @throws ResponseStatusException with 401 status if authentication is invalid
|
||||
*/
|
||||
public UUID getAuthenticatedUserUuid() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
|
||||
if (unsecured) {
|
||||
return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f");
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
|
||||
String stringUuid = jwt.getSubject();
|
||||
|
||||
try {
|
||||
return UUID.fromString(stringUuid);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid UUID format");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.alttd.altitudeweb.controllers.forms;
|
||||
|
||||
import com.alttd.altitudeweb.api.AppealsApi;
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryType;
|
||||
import com.alttd.altitudeweb.database.litebans.IdHistoryMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
|
||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
||||
import com.alttd.altitudeweb.mappers.AppealDataMapper;
|
||||
import com.alttd.altitudeweb.model.DiscordAppealDto;
|
||||
import com.alttd.altitudeweb.model.FormResponseDto;
|
||||
import com.alttd.altitudeweb.model.MinecraftAppealDto;
|
||||
import com.alttd.altitudeweb.model.UpdateMailDto;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import com.alttd.altitudeweb.services.mail.AppealMail;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
|
||||
public class AppealController implements AppealsApi {
|
||||
|
||||
private final AppealDataMapper mapper;
|
||||
private final AppealMail appealMail;
|
||||
private final com.alttd.altitudeweb.services.discord.AppealDiscord appealDiscord;
|
||||
|
||||
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
|
||||
@Override
|
||||
public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
|
||||
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
|
||||
}
|
||||
|
||||
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
|
||||
@Override
|
||||
public ResponseEntity<FormResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
|
||||
boolean success = true;
|
||||
CompletableFuture<Appeal> appealCompletableFuture = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Loading history by id");
|
||||
try {
|
||||
Appeal appeal = mapper.minecraftAppealDtoToAppeal(minecraftAppealDto);
|
||||
sqlSession.getMapper(AppealMapper.class).createAppeal(appeal);
|
||||
appealCompletableFuture.complete(appeal);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load history count", e);
|
||||
appealCompletableFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
Appeal appeal = appealCompletableFuture.join();
|
||||
HistoryRecord history = getHistory(appeal.historyType(), appeal.historyId());
|
||||
if (history == null) {
|
||||
throw new ResponseStatusException(HttpStatusCode.valueOf(404), "History not found");
|
||||
}
|
||||
// Send to Discord channels
|
||||
try {
|
||||
appealDiscord.sendAppealToDiscord(appeal, history);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send appeal {} to Discord", appeal.id(), e);
|
||||
success = false;
|
||||
}
|
||||
|
||||
appealMail.sendAppealNotification(appeal, history);
|
||||
|
||||
CompletableFuture<Optional<EmailVerification>> emailVerificationCompletableFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Retrieving mail by uuid and address");
|
||||
|
||||
EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class)
|
||||
.findByUserAndEmail(appeal.uuid(), appeal.email().toLowerCase());
|
||||
emailVerificationCompletableFuture.complete(Optional.ofNullable(verifiedMail));
|
||||
});
|
||||
Optional<EmailVerification> optionalEmailVerification = emailVerificationCompletableFuture.join();
|
||||
|
||||
if (optionalEmailVerification.isEmpty()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
EmailVerification emailVerification = optionalEmailVerification.get();
|
||||
if (!emailVerification.verified()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
if (!success) {
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Marking appeal {} as sent", appeal.id());
|
||||
sqlSession.getMapper(AppealMapper.class)
|
||||
.markAppealAsSent(appeal.id());
|
||||
});
|
||||
FormResponseDto appealResponseDto = new FormResponseDto(
|
||||
appeal.id().toString(),
|
||||
"Your appeal has been submitted. You will be notified when it has been reviewed.",
|
||||
true);
|
||||
return ResponseEntity.ok().body(appealResponseDto);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<FormResponseDto> updateMail(UpdateMailDto updateMailDto) {
|
||||
//TODO move to its own endpoint
|
||||
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Updating mail is not yet supported");
|
||||
}
|
||||
|
||||
private HistoryRecord getHistory(String type, int id) {
|
||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||
CompletableFuture<HistoryRecord> historyRecordCompletableFuture = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Loading history by id");
|
||||
try {
|
||||
HistoryRecord punishment = sqlSession.getMapper(IdHistoryMapper.class)
|
||||
.getRecentHistory(historyTypeEnum, id);
|
||||
historyRecordCompletableFuture.complete(punishment);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load history count", e);
|
||||
historyRecordCompletableFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return historyRecordCompletableFuture.join();
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package com.alttd.altitudeweb.controllers.forms;
|
||||
|
||||
import com.alttd.altitudeweb.api.ApplicationsApi;
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
|
||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
||||
import com.alttd.altitudeweb.mappers.StaffApplicationDataMapper;
|
||||
import com.alttd.altitudeweb.model.FormResponseDto;
|
||||
import com.alttd.altitudeweb.model.StaffApplicationDto;
|
||||
import com.alttd.altitudeweb.services.discord.StaffApplicationDiscord;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import com.alttd.altitudeweb.services.mail.StaffApplicationMail;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
|
||||
public class ApplicationController implements ApplicationsApi {
|
||||
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
private final StaffApplicationDataMapper staffApplicationDataMapper;
|
||||
private final StaffApplicationMail staffApplicationMail;
|
||||
private final StaffApplicationDiscord staffApplicationDiscord;
|
||||
|
||||
private final Instant open = Instant.parse("2025-10-18T00:00:00Z");
|
||||
private final Instant close = Instant.parse("2025-10-26T00:00:00Z");
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Boolean> getStaffApplicationsIsOpen() {
|
||||
return ResponseEntity.ok(isOpen());
|
||||
}
|
||||
|
||||
private boolean isOpen() {
|
||||
Instant now = Instant.now();
|
||||
return !now.isBefore(open) && !now.isAfter(close);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<FormResponseDto> submitStaffApplication(StaffApplicationDto staffApplicationDto) {
|
||||
if (!isOpen()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
UUID userUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
|
||||
String email = staffApplicationDto.getEmail() == null ? null : staffApplicationDto.getEmail().toLowerCase();
|
||||
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, email);
|
||||
if (optionalEmail.isEmpty() || !optionalEmail.get().verified()) {
|
||||
log.warn("User {} attempted to submit an application without a verified email {}", userUuid, email);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
// Map and persist application
|
||||
StaffApplication application = staffApplicationDataMapper.map(userUuid, staffApplicationDto);
|
||||
saveApplication(application);
|
||||
|
||||
String username = getUsername(userUuid);
|
||||
|
||||
try {
|
||||
if (!staffApplicationMail.sendApplicationEmail(username, application)) {
|
||||
log.warn("Failed to send staff application email for {}", application.id());
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error while sending staff application email for {}", application.id(), e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
|
||||
try {
|
||||
staffApplicationDiscord.sendApplicationToDiscord(username, application);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send staff application {} to Discord", application.id(), e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
try {
|
||||
markAsSent(application.id());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to mark application {} as sent", application.id(), e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
FormResponseDto response = buildResponse(application);
|
||||
return ResponseEntity.status(200).body(response);
|
||||
}
|
||||
|
||||
private void saveApplication(StaffApplication application) {
|
||||
CompletableFuture<Void> saveFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> {
|
||||
try {
|
||||
sqlSession.getMapper(StaffApplicationMapper.class).insert(application);
|
||||
saveFuture.complete(null);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to insert staff application", e);
|
||||
saveFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
saveFuture.join();
|
||||
}
|
||||
|
||||
private Optional<EmailVerification> fetchEmailVerification(UUID userUuid, String email) {
|
||||
CompletableFuture<Optional<EmailVerification>> emailVerificationFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> {
|
||||
EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class)
|
||||
.findByUserAndEmail(userUuid, email);
|
||||
emailVerificationFuture.complete(Optional.ofNullable(verifiedMail));
|
||||
});
|
||||
return emailVerificationFuture.join();
|
||||
}
|
||||
|
||||
private void markAsSent(UUID applicationId) {
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> sqlSession.getMapper(StaffApplicationMapper.class).markAsSent(applicationId));
|
||||
}
|
||||
|
||||
private FormResponseDto buildResponse(StaffApplication application) {
|
||||
String message = "Your staff application has been submitted. You will be notified when it has been reviewed.";
|
||||
return new FormResponseDto(
|
||||
application.id().toString(),
|
||||
message,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private String getUsername(UUID uuid) {
|
||||
CompletableFuture<String> usernameFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.LUCK_PERMS)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Loading username for uuid {}", uuid);
|
||||
try {
|
||||
String username = sqlSession.getMapper(UUIDUsernameMapper.class).getUsernameFromUUID(uuid.toString());
|
||||
usernameFuture.complete(username);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load username for uuid {}", uuid, e);
|
||||
usernameFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return usernameFuture.join();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.alttd.altitudeweb.controllers.forms;
|
||||
|
||||
import com.alttd.altitudeweb.api.MailApi;
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
|
||||
import com.alttd.altitudeweb.model.MailResponseDto;
|
||||
import com.alttd.altitudeweb.model.SubmitEmailDto;
|
||||
import com.alttd.altitudeweb.model.VerifyCodeDto;
|
||||
import com.alttd.altitudeweb.model.EmailEntryDto;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import com.alttd.altitudeweb.services.mail.MailVerificationService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RateLimit(limit = 60, timeValue = 1, timeUnit = TimeUnit.HOURS)
|
||||
public class MailController implements MailApi {
|
||||
|
||||
private final MailVerificationService mailVerificationService;
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
|
||||
@Override
|
||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit")
|
||||
public ResponseEntity<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) {
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream()
|
||||
.filter(EmailVerification::verified)
|
||||
.map(EmailVerification::email)
|
||||
.anyMatch(mail -> mail.equalsIgnoreCase(submitEmailDto.getEmail()));
|
||||
if (emailAlreadyVerified) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email already verified for user");
|
||||
}
|
||||
|
||||
EmailVerification saved = mailVerificationService.submitEmail(uuid, submitEmailDto.getEmail());
|
||||
MailResponseDto response = new MailResponseDto()
|
||||
.email(saved.email())
|
||||
.message("Verification email sent")
|
||||
.verified(false);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
@RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify")
|
||||
public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode());
|
||||
if (optionalEmailVerification.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code");
|
||||
}
|
||||
EmailVerification emailVerification = optionalEmailVerification.get();
|
||||
MailResponseDto response = new MailResponseDto()
|
||||
.email(emailVerification.email())
|
||||
.message("Email verified successfully")
|
||||
.verified(true);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend")
|
||||
public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) {
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
EmailVerification updated = mailVerificationService.resend(uuid, submitEmailDto.getEmail());
|
||||
if (updated == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
|
||||
}
|
||||
MailResponseDto response = new MailResponseDto()
|
||||
.email(updated.email())
|
||||
.message("Verification email resent")
|
||||
.verified(false);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
@RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete")
|
||||
public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) {
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail());
|
||||
if (!deleted) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
|
||||
}
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<List<EmailEntryDto>> getUserEmails() {
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
List<EmailVerification> emails = mailVerificationService.listAll(uuid);
|
||||
List<EmailEntryDto> result = emails.stream()
|
||||
.map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified()))
|
||||
.toList();
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
+111
-1
@@ -1,6 +1,7 @@
|
||||
package com.alttd.altitudeweb.controllers.history;
|
||||
|
||||
import com.alttd.altitudeweb.api.HistoryApi;
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import com.alttd.altitudeweb.model.HistoryCountDto;
|
||||
import com.alttd.altitudeweb.model.PunishmentHistoryListDto;
|
||||
@@ -8,9 +9,11 @@ import com.alttd.altitudeweb.setup.Connection;
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.litebans.*;
|
||||
import com.alttd.altitudeweb.model.PunishmentHistoryDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -20,8 +23,11 @@ import java.util.concurrent.TimeUnit;
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RateLimit(limit = 30, timeValue = 10, timeUnit = TimeUnit.SECONDS)
|
||||
@RequiredArgsConstructor
|
||||
public class HistoryApiController implements HistoryApi {
|
||||
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
|
||||
@Override
|
||||
public ResponseEntity<PunishmentHistoryListDto> getHistoryForAll(String userType, String type, Integer page) {
|
||||
return getHistoryForUsers(userType, type, "", page);
|
||||
@@ -229,4 +235,108 @@ public class HistoryApiController implements HistoryApi {
|
||||
.type(type)
|
||||
.id(historyRecord.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<PunishmentHistoryDto> updatePunishmentReason(String type, Integer id, String reason) {
|
||||
log.debug("Updating reason for {} id {} to {}", type, id, reason);
|
||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||
try {
|
||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
|
||||
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||
if (before == null) {
|
||||
result.complete(null);
|
||||
return;
|
||||
}
|
||||
int changed = editMapper.setReason(historyTypeEnum, id, reason);
|
||||
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
|
||||
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
|
||||
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to update reason for {} id {}", type, id, e);
|
||||
result.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
PunishmentHistoryDto body = result.join();
|
||||
if (body == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<PunishmentHistoryDto> updatePunishmentUntil(String type, Integer id, Long until) {
|
||||
log.debug("Updating until for {} id {} to {}", type, id, until);
|
||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||
try {
|
||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
|
||||
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||
if (before == null) {
|
||||
result.complete(null);
|
||||
return;
|
||||
}
|
||||
int changed = editMapper.setUntil(historyTypeEnum, id, until);
|
||||
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
|
||||
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
|
||||
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid until edit for type {} id {}: {}", type, id, e.getMessage());
|
||||
result.complete(null);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to update until for {} id {}", type, id, e);
|
||||
result.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
PunishmentHistoryDto body = result.join();
|
||||
if (body == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> removePunishment(String type, Integer id) {
|
||||
log.debug("Removing punishment for {} id {}", type, id);
|
||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
||||
try {
|
||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
||||
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
|
||||
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
|
||||
if (before == null) {
|
||||
result.complete(false);
|
||||
return;
|
||||
}
|
||||
UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
|
||||
int changed = editMapper.remove(historyTypeEnum, id);
|
||||
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",
|
||||
actorName, actorUuid, historyTypeEnum, id,
|
||||
before.getRemovedByName() == null ? 1 : 0, before.getRemovedByName(), before.getRemovedByReason(),
|
||||
changed);
|
||||
result.complete(changed > 0);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to remove punishment for {} id {}", type, id, e);
|
||||
result.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
Boolean ok = result.join();
|
||||
if (ok == null || !ok) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
+97
-18
@@ -1,18 +1,22 @@
|
||||
package com.alttd.altitudeweb.controllers.login;
|
||||
|
||||
import com.alttd.altitudeweb.api.LoginApi;
|
||||
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.litebans.RecentNamesMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.PrivilegedUser;
|
||||
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
||||
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
||||
import com.alttd.altitudeweb.model.UsernameDto;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
@@ -28,20 +32,26 @@ import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@EnableScheduling
|
||||
@RestController
|
||||
public class LoginController implements LoginApi {
|
||||
|
||||
private final JwtEncoder jwtEncoder;
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
|
||||
@Value("${login.secret:#{null}}")
|
||||
private String loginSecret;
|
||||
|
||||
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<>();
|
||||
|
||||
@Scheduled(fixedRate = 300000) // 5 minutes in milliseconds
|
||||
private void clearExpiredCacheEntries() {
|
||||
protected void clearExpiredCacheEntries() {
|
||||
Instant now = Instant.now();
|
||||
int initialCacheSize = cache.size();
|
||||
cache.entrySet().removeIf(entry -> entry.getValue().expiry().isBefore(now));
|
||||
@@ -58,6 +68,8 @@ public class LoginController implements LoginApi {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
log.info("{} is requesting a login code", uuid);
|
||||
|
||||
if (authorization == null || !authorization.startsWith("SECRET ")) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
@@ -73,31 +85,88 @@ public class LoginController implements LoginApi {
|
||||
.findFirst();
|
||||
|
||||
if (key.isPresent()) {
|
||||
log.info("{} got cached key: {}", uuid, key.get());
|
||||
return ResponseEntity.ok(key.get());
|
||||
}
|
||||
|
||||
String loginCode = generateLoginCode(uuidFromString);
|
||||
log.info("{} received login code: {}", uuid, loginCode);
|
||||
return ResponseEntity.ok(loginCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<UsernameDto> getUsername() {
|
||||
log.debug("Loading username for logged in user");
|
||||
|
||||
try {
|
||||
// Get authenticated UUID using the utility method
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
log.debug("Loaded username for logged in user {}", uuid);
|
||||
|
||||
// Create response with username
|
||||
UsernameDto usernameDto = new UsernameDto();
|
||||
usernameDto.setUsername(getUsername(uuid));
|
||||
log.debug("Loaded username for logged in user {}", usernameDto.getUsername());
|
||||
|
||||
return ResponseEntity.ok(usernameDto);
|
||||
|
||||
} catch (ResponseStatusException e) {
|
||||
// The utility method already throws proper exceptions, we just need to convert them to ResponseEntity
|
||||
return ResponseEntity.status(e.getStatusCode()).build();
|
||||
}
|
||||
}
|
||||
|
||||
private String getUsername(UUID uuid) {
|
||||
CompletableFuture<String> username = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Loading all history through logged in uuid");
|
||||
try {
|
||||
String temp = sqlSession
|
||||
.getMapper(RecentNamesMapper.class)
|
||||
.getUsername(uuid.toString());
|
||||
username.complete(temp);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to find username for uuid {}", uuid, e);
|
||||
username.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return username.join();
|
||||
}
|
||||
|
||||
@Value("${UNSECURED:#{false}}")
|
||||
private boolean unsecured;
|
||||
|
||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
|
||||
@Override
|
||||
public ResponseEntity<String> login(String code) {
|
||||
CacheEntry cacheEntry1 = new CacheEntry(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"), Instant.now().plusSeconds(TimeUnit.DAYS.toSeconds(1)));
|
||||
cache.put("23232323", cacheEntry1);
|
||||
if (unsecured) {
|
||||
log.warn("Unsecured login is enabled, skipping login validation!");
|
||||
} else {
|
||||
log.info("Received login request with code {}", code);
|
||||
}
|
||||
if (code == null) {
|
||||
log.warn("Received null login code");
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
CacheEntry cacheEntry = cache.get(code);
|
||||
if (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now())) {
|
||||
if (!unsecured && (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now()))) {
|
||||
log.warn("Received invalid login code {}", code);
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
if (unsecured && cacheEntry == null) {
|
||||
cacheEntry = new CacheEntry(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"), Instant.now().plusSeconds(TimeUnit.DAYS.toSeconds(1)));
|
||||
}
|
||||
|
||||
String token = generateToken(cacheEntry.uuid);
|
||||
log.debug("Generated token for user {} with token {}", cacheEntry.uuid, token);
|
||||
|
||||
cache.remove(code);
|
||||
|
||||
log.debug("Generated token for user {}", cacheEntry.uuid);
|
||||
return ResponseEntity.ok(token);
|
||||
}
|
||||
|
||||
@@ -134,34 +203,44 @@ public class LoginController implements LoginApi {
|
||||
Instant now = Instant.now();
|
||||
//TODO make a JWT for renewing and one for storing permissions for a session (expiry 1 hour)
|
||||
Instant expiryTime = now.plusSeconds(TimeUnit.DAYS.toSeconds(30));
|
||||
CompletableFuture<PrivilegedUser> privilegedUserCompletableFuture = new CompletableFuture<>();
|
||||
CompletableFuture<Optional<PrivilegedUser>> privilegedUserCompletableFuture = new CompletableFuture<>();
|
||||
List<PermissionClaimDto> claimList = new ArrayList<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> {
|
||||
try {
|
||||
PrivilegedUser privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class)
|
||||
.getUserByUuid(uuid.toString());
|
||||
log.debug("Loading user by uuid {}", uuid.toString());
|
||||
PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class);
|
||||
Optional<PrivilegedUser> privilegedUser = mapper
|
||||
.getUserByUuid(uuid);
|
||||
|
||||
if (privilegedUser.isEmpty()) {
|
||||
int privilegedUserId = mapper.createPrivilegedUser(uuid);
|
||||
privilegedUserCompletableFuture.complete(
|
||||
Optional.of(new PrivilegedUser(privilegedUserId, uuid, List.of())));
|
||||
} else {
|
||||
privilegedUserCompletableFuture.complete(privilegedUser);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load user by uuid", e);
|
||||
privilegedUserCompletableFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
PrivilegedUser privilegedUser = privilegedUserCompletableFuture.join();
|
||||
Optional<PrivilegedUser> privilegedUser = privilegedUserCompletableFuture.join();
|
||||
claimList.add(PermissionClaimDto.USER);
|
||||
if (privilegedUser != null) {
|
||||
privilegedUser.getPermissions().forEach(permission -> {
|
||||
privilegedUser.ifPresent(user -> user.getPermissions().forEach(permission -> {
|
||||
try {
|
||||
claimList.add(PermissionClaimDto.valueOf(permission));
|
||||
claimList.add(PermissionClaimDto.fromValue(permission));
|
||||
log.debug("Added permission claim {}", permission);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Received invalid permission claim: {}", permission);
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
log.debug("Generated token for user {} with claims {}", uuid.toString(),
|
||||
claimList.stream().map(PermissionClaimDto::getValue).toList());
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||
.issuer("altitudeweb")
|
||||
.claim("authorities", claimList.stream().map(PermissionClaimDto::getValue).toList())
|
||||
.issuer(serverAddress)
|
||||
.claim("authorities",
|
||||
claimList.stream().map(PermissionClaimDto::getValue).toList())
|
||||
.issuedAt(now)
|
||||
.expiresAt(expiryTime)
|
||||
.subject(uuid.toString())
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package com.alttd.altitudeweb.controllers.particles;
|
||||
|
||||
import com.alttd.altitudeweb.api.ParticlesApi;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
public class ParticleController implements ParticlesApi {
|
||||
|
||||
@Value("${login.secret:#{null}}")
|
||||
private String loginSecret;
|
||||
|
||||
@Value("${particles.file_path}")
|
||||
private String particlesFilePath;
|
||||
|
||||
@Value("${notification.server.url:http://localhost:8080}")
|
||||
private String notificationServerUrl;
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Resource> downloadFile(String authorization, String filename) throws Exception {
|
||||
if (authorization == null || !authorization.equals(loginSecret)) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
File file = new File(particlesFilePath);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
log.error("Particles file path {} is not a directory, not downloading particles file", particlesFilePath);
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
File targetFile = new File(file, filename);
|
||||
return getFileForDownload(targetFile, filename);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Resource> downloadFileForUser(String authorization, String uuid, String filename) throws Exception {
|
||||
if (authorization == null || !authorization.equals(loginSecret)) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
File file = new File(particlesFilePath);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
log.error("Particles file path {} is not a directory, not downloading particles user file", particlesFilePath);
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
File targetDir = new File(file, uuid);
|
||||
if (targetDir.exists()) {
|
||||
return getFileForDownload(targetDir, filename);
|
||||
} else {
|
||||
log.warn("User {} does not have a directory for particles files", uuid);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<Resource> getFileForDownload(File file, String filename) {
|
||||
File targetFile = new File(file, filename);
|
||||
if (!targetFile.exists()) {
|
||||
log.warn("Particles file {} does not exist", targetFile.getAbsolutePath());
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (!targetFile.isFile()) {
|
||||
log.warn("Particles file {} is not a file", targetFile.getAbsolutePath());
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
|
||||
try {
|
||||
Path path = targetFile.toPath();
|
||||
ByteArrayResource resource = new ByteArrayResource(Files.readAllBytes(path));
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentLength(targetFile.length())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.body(resource);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read particles file {}: {}", targetFile.getAbsolutePath(), e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) throws Exception {
|
||||
File file = new File(particlesFilePath);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
log.error("Particles file path {} is not a directory, not saving particles file", particlesFilePath);
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
|
||||
notifyServerOfFileUpload(filename);
|
||||
return voidResponseEntity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) throws Exception {
|
||||
File file = new File(particlesFilePath);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
log.error("Particles file path {} is not a directory, not saving particles user file", particlesFilePath);
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
File targetDir = new File(file, uuid);
|
||||
if (!file.exists()) {
|
||||
log.debug("Creating particles directory {}", targetDir.getAbsolutePath());
|
||||
if (targetDir.mkdirs()) {
|
||||
log.info("Created particles user directory {}", targetDir.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
|
||||
notifyServerOfFileUpload(uuid, filename);
|
||||
return voidResponseEntity;
|
||||
}
|
||||
|
||||
private void notifyServerOfFileUpload(String filename) {
|
||||
String notificationUrl = String.format("%s/notify/%s.json", notificationServerUrl, filename);
|
||||
sendNotification(notificationUrl, String.format("file upload: %s", filename));
|
||||
}
|
||||
|
||||
private void notifyServerOfFileUpload(String uuid, String filename) {
|
||||
String notificationUrl = String.format("%s/notify/%s/%s.json", notificationServerUrl, uuid, filename);
|
||||
sendNotification(notificationUrl, String.format("file upload for user %s: %s", uuid, filename));
|
||||
}
|
||||
|
||||
private void sendNotification(String notificationUrl, String logDescription) {
|
||||
try {
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(notificationUrl, String.class);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
log.info("Successfully notified server of {}", logDescription);
|
||||
} else {
|
||||
log.warn("Failed to notify server of {}, status: {}",
|
||||
logDescription, response.getStatusCode());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error notifying server of {}", logDescription, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) {
|
||||
File targetFile = new File(dir, filename);
|
||||
if (!Files.isWritable(targetFile.toPath())) {
|
||||
log.error("Particles file {} is not writable", targetFile.getAbsolutePath());
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
|
||||
if (targetFile.exists()) {
|
||||
log.warn("Overwriting existing particles file {}", targetFile.getAbsolutePath());
|
||||
}
|
||||
|
||||
try {
|
||||
content.transferTo(targetFile);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to write particles file {}", targetFile.getAbsolutePath(), e);
|
||||
return ResponseEntity.status(500).build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.alttd.altitudeweb.controllers.site;
|
||||
|
||||
import com.alttd.altitudeweb.api.SiteApi;
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.model.VoteDataDto;
|
||||
import com.alttd.altitudeweb.model.VoteStatsDto;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import com.alttd.altitudeweb.services.site.VoteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RateLimit(limit = 2, timeValue = 10, timeUnit = TimeUnit.SECONDS)
|
||||
public class SiteController implements SiteApi {
|
||||
|
||||
private final VoteService voteService;
|
||||
private final AuthenticatedUuid authenticatedUuid;
|
||||
|
||||
@Override
|
||||
public ResponseEntity<VoteDataDto> getVoteStats() {
|
||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||
Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid);
|
||||
if (optionalVoteDataDto.isEmpty()) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
VoteDataDto voteDataDto = optionalVoteDataDto.get();
|
||||
return ResponseEntity.ok(voteDataDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.alttd.altitudeweb.mappers;
|
||||
|
||||
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
|
||||
import com.alttd.altitudeweb.model.MinecraftAppealDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class AppealDataMapper {
|
||||
public MinecraftAppealDto appealToMinecraftAppealDto(Appeal appeal) {
|
||||
MinecraftAppealDto minecraftAppealDto = new MinecraftAppealDto();
|
||||
minecraftAppealDto.setAppeal(appeal.reason());
|
||||
minecraftAppealDto.setUsername(appeal.username());
|
||||
minecraftAppealDto.setUuid(appeal.uuid());
|
||||
minecraftAppealDto.setEmail(appeal.email());
|
||||
return minecraftAppealDto;
|
||||
}
|
||||
|
||||
public Appeal minecraftAppealDtoToAppeal(MinecraftAppealDto minecraftAppealDto) {
|
||||
return new Appeal(
|
||||
UUID.randomUUID(),
|
||||
minecraftAppealDto.getUuid(),
|
||||
minecraftAppealDto.getPunishmentType().toString(),
|
||||
minecraftAppealDto.getPunishmentId(),
|
||||
minecraftAppealDto.getUsername(),
|
||||
minecraftAppealDto.getAppeal(),
|
||||
Instant.now(),
|
||||
null,
|
||||
minecraftAppealDto.getEmail(),
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.alttd.altitudeweb.mappers;
|
||||
|
||||
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
|
||||
import com.alttd.altitudeweb.model.StaffApplicationDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class StaffApplicationDataMapper {
|
||||
|
||||
/**
|
||||
* Maps the incoming DTO and the authenticated user's UUID to a StaffApplication entity.
|
||||
* Normalizes and prepares fields as needed (lowercase email, join availableDays, timestamps, ids).
|
||||
*/
|
||||
public StaffApplication map(UUID userUuid, StaffApplicationDto dto) {
|
||||
String email = dto.getEmail() == null ? null : dto.getEmail().toLowerCase();
|
||||
String availableDaysJoined = joinList(dto.getAvailableDays());
|
||||
|
||||
return new StaffApplication(
|
||||
UUID.randomUUID(),
|
||||
userUuid,
|
||||
email,
|
||||
dto.getAge(),
|
||||
dto.getDiscordUsername(),
|
||||
Boolean.TRUE.equals(dto.getMeetsRequirements()),
|
||||
dto.getPronouns(),
|
||||
dto.getJoinDate(),
|
||||
dto.getWeeklyPlaytime(),
|
||||
availableDaysJoined,
|
||||
dto.getAvailableTimes(),
|
||||
dto.getPreviousExperience(),
|
||||
dto.getPluginExperience(),
|
||||
dto.getModeratorExpectations(),
|
||||
dto.getAdditionalInfo(),
|
||||
Instant.now(),
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private String joinList(List<String> list) {
|
||||
if (list == null) return null;
|
||||
// Avoid NPEs and trim entries
|
||||
return list.stream()
|
||||
.filter(s -> s != null && !s.isBlank())
|
||||
.map(String::trim)
|
||||
.collect(Collectors.joining(","));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.alttd.altitudeweb.mappers;
|
||||
|
||||
import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow;
|
||||
import com.alttd.altitudeweb.model.VoteDataDto;
|
||||
import com.alttd.altitudeweb.model.VoteInfoDto;
|
||||
import com.alttd.altitudeweb.model.VoteStatsDto;
|
||||
import com.alttd.altitudeweb.model.VoteStreakDto;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class VotingStatsRowToVoteDataDto {
|
||||
|
||||
public VoteDataDto map(VotingStatsRow votingStatsRow) {
|
||||
VoteDataDto voteDataDto = new VoteDataDto();
|
||||
voteDataDto.setVoteStats(getVoteStats(votingStatsRow));
|
||||
voteDataDto.setVoteStreak(getVoteStreak(votingStatsRow));
|
||||
voteDataDto.setBestVoteStreak(getBestVoteStreak(votingStatsRow));
|
||||
voteDataDto.setAllVoteInfo(getVoteInfo(votingStatsRow.lastVotes()));
|
||||
return voteDataDto;
|
||||
}
|
||||
|
||||
private VoteStreakDto getVoteStreak(VotingStatsRow votingStatsRow) {
|
||||
VoteStreakDto voteStreakDto = new VoteStreakDto();
|
||||
voteStreakDto.setDailyStreak(votingStatsRow.dailyStreak());
|
||||
voteStreakDto.setWeeklyStreak(votingStatsRow.weeklyStreak());
|
||||
voteStreakDto.setMonthlyStreak(votingStatsRow.monthlyStreak());
|
||||
return voteStreakDto;
|
||||
}
|
||||
|
||||
private VoteStreakDto getBestVoteStreak(VotingStatsRow votingStatsRow) {
|
||||
VoteStreakDto voteStreakDto = new VoteStreakDto();
|
||||
voteStreakDto.setDailyStreak(votingStatsRow.bestDailyStreak());
|
||||
voteStreakDto.setWeeklyStreak(votingStatsRow.bestWeeklyStreak());
|
||||
voteStreakDto.setMonthlyStreak(votingStatsRow.bestMonthlyStreak());
|
||||
return voteStreakDto;
|
||||
}
|
||||
|
||||
|
||||
private VoteStatsDto getVoteStats(VotingStatsRow votingStatsRow) {
|
||||
VoteStatsDto voteStatsDto = new VoteStatsDto();
|
||||
voteStatsDto.setDaily(votingStatsRow.totalVotesToday());
|
||||
voteStatsDto.setWeekly(votingStatsRow.totalVotesThisWeek());
|
||||
voteStatsDto.setMonthly(votingStatsRow.totalVotesThisMonth());
|
||||
voteStatsDto.setTotal(votingStatsRow.totalVotesAllTime());
|
||||
return voteStatsDto;
|
||||
}
|
||||
|
||||
private List<VoteInfoDto> getVoteInfo(String lastVotes) {
|
||||
return Arrays.stream(lastVotes.split("%line%"))
|
||||
.map(voteInfo -> {
|
||||
String[] siteAndTimestamp = voteInfo.split("//");
|
||||
VoteInfoDto voteInfoDto = new VoteInfoDto();
|
||||
voteInfoDto.setSiteName(siteAndTimestamp[0]);
|
||||
voteInfoDto.setLastVoteTimestamp(Long.parseLong(siteAndTimestamp[1]));
|
||||
return voteInfoDto;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.alttd.altitudeweb.services.discord;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.discord.OutputChannel;
|
||||
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryCountMapper;
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryType;
|
||||
import com.alttd.altitudeweb.database.litebans.UserType;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import com.alttd.webinterface.send_message.DiscordSender;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AppealDiscord {
|
||||
|
||||
private static final String OUTPUT_TYPE = "APPEAL";
|
||||
|
||||
public void sendAppealToDiscord(Appeal appeal, HistoryRecord history) {
|
||||
// Fetch channels
|
||||
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
|
||||
try {
|
||||
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
|
||||
.getChannelsWithOutputType(OUTPUT_TYPE);
|
||||
channelsFuture.complete(channels);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
|
||||
channelsFuture.complete(new ArrayList<>());
|
||||
}
|
||||
});
|
||||
|
||||
CompletableFuture<Integer> bansF = getCountAsync(HistoryType.BAN, appeal.uuid());
|
||||
CompletableFuture<Integer> mutesF = getCountAsync(HistoryType.MUTE, appeal.uuid());
|
||||
CompletableFuture<Integer> warnsF = getCountAsync(HistoryType.WARN, appeal.uuid());
|
||||
CompletableFuture<Integer> kicksF = getCountAsync(HistoryType.KICK, appeal.uuid());
|
||||
|
||||
List<OutputChannel> channels = channelsFuture.join();
|
||||
int bans = bansF.join();
|
||||
int mutes = mutesF.join();
|
||||
int warns = warnsF.join();
|
||||
int kicks = kicksF.join();
|
||||
|
||||
if (channels.isEmpty()) {
|
||||
log.warn("No Discord output channels found for type {}. Skipping Discord send.", OUTPUT_TYPE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build embed
|
||||
boolean active = history.getUntil() == null || history.getUntil() <= 0 || history.getUntil() > System.currentTimeMillis();
|
||||
String createdAt = formatInstant(appeal.createdAt());
|
||||
|
||||
List<DiscordSender.EmbedField> fields = new ArrayList<>();
|
||||
// Group: User
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"User",
|
||||
"Username: " + safe(appeal.username()) + "\n" +
|
||||
"UUID: " + safe(String.valueOf(appeal.uuid())) + "\n" +
|
||||
"Email: " + safe(appeal.email()) + "\n" +
|
||||
"Submitted: " + createdAt,
|
||||
false
|
||||
));
|
||||
// Group: Punishment
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"Punishment",
|
||||
"Type: " + safe(String.valueOf(appeal.historyType())) + "\n" +
|
||||
"ID: " + safe(String.valueOf(appeal.historyId())) + "\n" +
|
||||
"Reason: " + safe(history.getReason()) + "\n" +
|
||||
"Active: " + active,
|
||||
false
|
||||
));
|
||||
// Group: Previous punishments
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"Previous punishments",
|
||||
"Bans: " + bans + "\n" +
|
||||
"Mutes: " + mutes + "\n" +
|
||||
"Warnings: " + warns + "\n" +
|
||||
"Kicks: " + kicks,
|
||||
true
|
||||
));
|
||||
|
||||
String description = safe(appeal.reason());
|
||||
|
||||
List<Long> channelIds = channels.stream()
|
||||
.map(OutputChannel::channel)
|
||||
.toList();
|
||||
|
||||
// colorRgb = null (use default), timestamp = appeal.createdAt if available
|
||||
Instant timestamp = appeal.createdAt() != null ? appeal.createdAt() : Instant.now();
|
||||
DiscordSender.getInstance().sendEmbedToChannels(
|
||||
channelIds,
|
||||
"New Appeal Submitted",
|
||||
description,
|
||||
fields,
|
||||
null,
|
||||
timestamp,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private CompletableFuture<Integer> getCountAsync(HistoryType type, java.util.UUID uuid) {
|
||||
CompletableFuture<Integer> future = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sql -> {
|
||||
try {
|
||||
Integer count = sql.getMapper(HistoryCountMapper.class)
|
||||
.getUuidPunishmentCount(type, UserType.PLAYER, uuid);
|
||||
future.complete(count == null ? 0 : count);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load punishment count for {} ({})", type, uuid, e);
|
||||
future.complete(0);
|
||||
}
|
||||
});
|
||||
return future;
|
||||
}
|
||||
|
||||
private String safe(String s) {
|
||||
return s == null ? "unknown" : s;
|
||||
}
|
||||
|
||||
private String formatInstant(Instant instant) {
|
||||
if (instant == null) return "unknown";
|
||||
return instant.atZone(ZoneId.of("UTC"))
|
||||
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.alttd.altitudeweb.services.discord;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.discord.OutputChannel;
|
||||
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import com.alttd.webinterface.send_message.DiscordSender;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class StaffApplicationDiscord {
|
||||
|
||||
private static final String OUTPUT_TYPE = "STAFF_APPLICATION";
|
||||
|
||||
public void sendApplicationToDiscord(String username, StaffApplication application) {
|
||||
// Fetch channels for staff applications
|
||||
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
|
||||
try {
|
||||
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
|
||||
.getChannelsWithOutputType(OUTPUT_TYPE);
|
||||
channelsFuture.complete(channels);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
|
||||
channelsFuture.complete(new ArrayList<>());
|
||||
}
|
||||
});
|
||||
|
||||
List<OutputChannel> channels = channelsFuture.join();
|
||||
if (channels.isEmpty()) {
|
||||
log.warn("No Discord output channels found for type {}. Skipping Discord send.", OUTPUT_TYPE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build embed content
|
||||
List<DiscordSender.EmbedField> fields = new ArrayList<>();
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"Applicant",
|
||||
"Username: " + safe(username) + "\n" +
|
||||
"Discord: " + safe(application.discordUsername()) + "\n" +
|
||||
"Email: " + safe(application.email()) + "\n" +
|
||||
"Age: " + safe(String.valueOf(application.age())) + "\n" +
|
||||
"Meets reqs: " + (application.meetsRequirements() != null && application.meetsRequirements()),
|
||||
false
|
||||
));
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"Availability",
|
||||
"Days: " + safe(application.availableDays()) + "\n" +
|
||||
"Times: " + safe(application.availableTimes()),
|
||||
false
|
||||
));
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"Experience",
|
||||
"Previous: " + safe(application.previousExperience()) + "\n" +
|
||||
"Plugins: " + safe(application.pluginExperience()) + "\n" +
|
||||
"Expectations: " + safe(application.moderatorExpectations()),
|
||||
false
|
||||
));
|
||||
if (application.additionalInfo() != null && !application.additionalInfo().isBlank()) {
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"Additional Info",
|
||||
application.additionalInfo(),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
List<Long> channelIds = channels.stream()
|
||||
.map(OutputChannel::channel)
|
||||
.toList();
|
||||
|
||||
Instant timestamp = application.createdAt() != null ? application.createdAt() : Instant.now();
|
||||
DiscordSender.getInstance().sendEmbedToChannels(
|
||||
channelIds,
|
||||
"New Staff Application Submitted",
|
||||
"Join date: " + (application.joinDate() != null ? application.joinDate().toString() : "unknown") +
|
||||
"\nSubmitted: " + formatInstant(timestamp),
|
||||
fields,
|
||||
null,
|
||||
timestamp,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private String safe(String s) {
|
||||
return s == null ? "unknown" : s;
|
||||
}
|
||||
|
||||
private String formatInstant(Instant instant) {
|
||||
if (instant == null) return "unknown";
|
||||
return instant.atZone(ZoneId.of("UTC"))
|
||||
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.alttd.altitudeweb.services.mail;
|
||||
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppealMail {
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
private final SpringTemplateEngine templateEngine;
|
||||
|
||||
@Value("${spring.mail.username}")
|
||||
private String fromEmail;
|
||||
|
||||
private static final String APPEAL_EMAIL = "[email protected]";
|
||||
|
||||
/**
|
||||
* Sends an email notification about the appeal to both the user and the appeals team.
|
||||
*
|
||||
* @param appeal The appeal object containing all necessary information
|
||||
*/
|
||||
public void sendAppealNotification(Appeal appeal, HistoryRecord history) {
|
||||
try {
|
||||
sendEmailToAppealsTeam(appeal, history);
|
||||
|
||||
log.info("Appeal notification emails sent successfully for appeal ID: {}", appeal.id());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send appeal notification emails for appeal ID: {}", appeal.id(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendEmailToAppealsTeam(Appeal appeal, HistoryRecord history) throws MessagingException {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true);
|
||||
|
||||
helper.setFrom(fromEmail);
|
||||
helper.setTo(APPEAL_EMAIL);
|
||||
helper.setReplyTo(appeal.email());
|
||||
helper.setSubject("New Appeal Submitted - " + appeal.username());
|
||||
|
||||
Context context = new Context();
|
||||
context.setVariable("appeal", appeal);
|
||||
context.setVariable("history", history);
|
||||
context.setVariable("createdAt", appeal.createdAt()
|
||||
.atZone(ZoneId.of("UTC"))
|
||||
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'")));
|
||||
context.setVariable("active", history.getUntil() <= 0 || history.getUntil() > System.currentTimeMillis());
|
||||
String content = templateEngine.process("appeal-email", context);
|
||||
|
||||
helper.setText(content, true);
|
||||
mailSender.send(message);
|
||||
}
|
||||
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package com.alttd.altitudeweb.services.mail;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
|
||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MailVerificationService {
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
|
||||
@Value("${spring.mail.username}")
|
||||
private String fromEmail;
|
||||
|
||||
public java.util.List<EmailVerification> listAll(UUID userUuid) {
|
||||
java.util.concurrent.CompletableFuture<java.util.List<EmailVerification>> future = new java.util.concurrent.CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sql -> {
|
||||
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
|
||||
future.complete(mapper.findAllByUser(userUuid));
|
||||
});
|
||||
return future.join();
|
||||
}
|
||||
|
||||
public EmailVerification submitEmail(UUID userUuid, String email) {
|
||||
String code = generateCode();
|
||||
Instant now = Instant.now();
|
||||
final String finalEmail = email.toLowerCase();
|
||||
|
||||
CompletableFuture<EmailVerification> future = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sql -> {
|
||||
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
|
||||
EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
|
||||
EmailVerification toPersist;
|
||||
if (existing == null) {
|
||||
toPersist = new EmailVerification(UUID.randomUUID(), userUuid, finalEmail, code, false, now, null, now);
|
||||
mapper.insert(toPersist);
|
||||
} else {
|
||||
mapper.updateCodeAndLastSent(existing.id(), code, now);
|
||||
toPersist = new EmailVerification(existing.id(), userUuid, finalEmail, code, false, existing.createdAt(), null, now);
|
||||
}
|
||||
future.complete(toPersist);
|
||||
});
|
||||
EmailVerification saved = future.join();
|
||||
sendVerificationEmail(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Optional<EmailVerification> verifyCode(UUID userUuid, String code) {
|
||||
CompletableFuture<Optional<EmailVerification>> future = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sql -> {
|
||||
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
|
||||
EmailVerification found = mapper.findByUserAndCode(userUuid, code);
|
||||
if (found == null) {
|
||||
future.complete(Optional.empty());
|
||||
return;
|
||||
}
|
||||
mapper.markVerified(found.id(), Instant.now());
|
||||
future.complete(Optional.of(found));
|
||||
});
|
||||
return future.join();
|
||||
}
|
||||
|
||||
public EmailVerification resend(UUID userUuid, String email) {
|
||||
String code = generateCode();
|
||||
Instant now = Instant.now();
|
||||
final String finalEmail = email.toLowerCase();
|
||||
CompletableFuture<EmailVerification> future = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sql -> {
|
||||
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
|
||||
EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
|
||||
if (existing != null) {
|
||||
mapper.updateCodeAndLastSent(existing.id(), code, now);
|
||||
future.complete(new EmailVerification(existing.id(), userUuid,
|
||||
finalEmail, code, false, existing.createdAt(), null, now));
|
||||
} else {
|
||||
future.complete(null);
|
||||
}
|
||||
});
|
||||
EmailVerification updated = future.join();
|
||||
if (updated != null) {
|
||||
sendVerificationEmail(updated);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
public boolean delete(UUID userUuid, String email) {
|
||||
final String finalEmail = email.toLowerCase();
|
||||
CompletableFuture<Boolean> future = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sql -> {
|
||||
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
|
||||
EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
|
||||
if (existing != null) {
|
||||
mapper.deleteByUserAndEmail(userUuid, finalEmail);
|
||||
future.complete(true);
|
||||
} else {
|
||||
future.complete(false);
|
||||
}
|
||||
});
|
||||
return future.join();
|
||||
}
|
||||
|
||||
private void sendVerificationEmail(EmailVerification emailVerification) {
|
||||
try {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true);
|
||||
helper.setFrom(fromEmail);
|
||||
helper.setTo(emailVerification.email().toLowerCase());
|
||||
helper.setSubject("Your verification code");
|
||||
helper.setText("Your verification code is: " + emailVerification.verificationCode(), false);
|
||||
mailSender.send(message);
|
||||
} catch (MessagingException e) {
|
||||
log.error("Failed to send verification email to {}", emailVerification.email(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private String generateCode() {
|
||||
// 6-digit numeric code
|
||||
Random random = new Random();
|
||||
int num = 100000 + random.nextInt(899999);
|
||||
return String.valueOf(num);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.alttd.altitudeweb.services.mail;
|
||||
|
||||
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class StaffApplicationMail {
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
private final SpringTemplateEngine templateEngine;
|
||||
|
||||
@Value("${spring.mail.username}")
|
||||
private String fromEmail;
|
||||
|
||||
private static final String STAFF_APPLICATION_EMAIL = "[email protected]";
|
||||
|
||||
/**
|
||||
* Sends an email with the staff application details to the staff applications team mailbox.
|
||||
* Returns true if the email was sent successfully.
|
||||
*/
|
||||
public boolean sendApplicationEmail(String username, StaffApplication application) {
|
||||
try {
|
||||
doSend(username, application);
|
||||
log.info("Staff application email sent successfully for application ID: {}", application.id());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send staff application email for application ID: {}", application.id(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void doSend(String username, StaffApplication application) throws MessagingException {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true);
|
||||
|
||||
helper.setFrom(fromEmail);
|
||||
helper.setTo(STAFF_APPLICATION_EMAIL);
|
||||
helper.setReplyTo(application.email());
|
||||
helper.setSubject("Staff Application: " + safe(application.discordUsername()));
|
||||
|
||||
// Prepare template context
|
||||
String createdAt = application.createdAt()
|
||||
.atZone(ZoneId.of("UTC"))
|
||||
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
|
||||
Context context = new Context();
|
||||
context.setVariable("application", application);
|
||||
context.setVariable("createdAt", createdAt);
|
||||
context.setVariable("username", username);
|
||||
|
||||
String content = templateEngine.process("staff-application-email", context);
|
||||
helper.setText(content, true);
|
||||
mailSender.send(message);
|
||||
}
|
||||
|
||||
private String safe(String s) {
|
||||
return s == null ? "unknown" : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.alttd.altitudeweb.services.site;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper;
|
||||
import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow;
|
||||
import com.alttd.altitudeweb.mappers.VotingStatsRowToVoteDataDto;
|
||||
import com.alttd.altitudeweb.model.VoteDataDto;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VoteService {
|
||||
|
||||
private final VotingStatsRowToVoteDataDto votingStatsRowToVoteDataDto;
|
||||
|
||||
public Optional<VoteDataDto> getVoteStats(UUID uuid) {
|
||||
CompletableFuture<Optional<VoteDataDto>> voteDataDtoFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.VOTING_PLUGIN).runQuery(sqlSession -> {
|
||||
try {
|
||||
VotingPluginUsersMapper votingPluginUsersMapper = sqlSession.getMapper(VotingPluginUsersMapper.class);
|
||||
Optional<VotingStatsRow> optionalVotingStatsRow = votingPluginUsersMapper.getStatsByUuid(uuid);
|
||||
if (optionalVotingStatsRow.isEmpty()) {
|
||||
log.debug("No voting stats found for {}", uuid);
|
||||
voteDataDtoFuture.complete(Optional.empty());
|
||||
return;
|
||||
}
|
||||
|
||||
VotingStatsRow votingStatsRow = optionalVotingStatsRow.get();
|
||||
|
||||
VoteDataDto voteDataDto = votingStatsRowToVoteDataDto.map(votingStatsRow);
|
||||
voteDataDtoFuture.complete(Optional.of(voteDataDto));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get vote data for {}", uuid, e);
|
||||
voteDataDtoFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return voteDataDtoFuture.join();//TODO handle exception
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,5 @@ database.host=${DB_HOST:localhost}
|
||||
database.user=${DB_USER:root}
|
||||
database.password=${DB_PASSWORD:root}
|
||||
cors.allowed-origins=${CORS:https://beta.alttd.com}
|
||||
logging.level.com.alttd.altitudeweb=INFO
|
||||
my-server.address=${SERVER_ADDRESS:https://beta.alttd.com}
|
||||
logging.level.com.alttd.altitudeweb=DEBUG
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
spring.application.name=AltitudeWeb
|
||||
database.name=${DB_NAME:web_db}
|
||||
database.port=${DB_PORT:3306}
|
||||
database.host=${DB_HOST:localhost}
|
||||
database.user=${DB_USER:root}
|
||||
database.password=${DB_PASSWORD:root}
|
||||
cors.allowed-origins=${CORS:http://localhost:4200}
|
||||
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080,http://localhost:80}
|
||||
my-server.address=${SERVER_ADDRESS:http://localhost:8080}
|
||||
logging.level.com.alttd.altitudeweb=DEBUG
|
||||
logging.level.org.springframework.security=DEBUG
|
||||
|
||||
@@ -6,4 +6,15 @@ database.user=${DB_USER:root}
|
||||
database.password=${DB_PASSWORD:root}
|
||||
cors.allowed-origins=${CORS:https://alttd.com}
|
||||
login.secret=${LOGIN_SECRET:SET_TOKEN}
|
||||
particles.file_path=${user.home}/.altitudeweb/particles
|
||||
notification.server.url=${SERVER_IP:10.0.0.107}:${SERVER_PORT:8080}
|
||||
my-server.address=${SERVER_ADDRESS:https://alttd.com}
|
||||
logging.level.com.alttd.altitudeweb=INFO
|
||||
discord.token=${DISCORD_TOKEN}
|
||||
spring.mail.host=${MAIL_HOST:smtp.zoho.com}
|
||||
spring.mail.port=${MAIL_PORT:465}
|
||||
spring.mail.username=${MAIL_USER}
|
||||
spring.mail.password=${MAIL_PASSWORD}
|
||||
spring.mail.properties.mail.smtp.auth=true
|
||||
spring.mail.properties.mail.smtp.ssl.enable=true
|
||||
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Appeal Notification</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'minecraft-title';
|
||||
src: url('https://beta.alttd.com/public/fonts/minecraft-title.ttf') format('truetype'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-title.eot') format('embedded-opentype'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-title.svg') format('svg'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-title.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'minecraft-text';
|
||||
src: url('https://beta.alttd.com/public/fonts/minecraft-text.ttf') format('truetype'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-text.eot') format('embedded-opentype'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-text.svg') format('svg'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-text.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'opensans';
|
||||
src: url('https://beta.alttd.com/public/fonts/opensans.ttf') format('truetype'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans.eot') format('embedded-opentype'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans.svg') format('svg'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'opensans-bold';
|
||||
src: url('https://beta.alttd.com/public/fonts/opensans-bold.ttf') format('truetype'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans-bold.eot') format('embedded-opentype'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans-bold.svg') format('svg'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans-bold.woff') format('woff');
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'minecraft-title', sans-serif;
|
||||
}
|
||||
|
||||
.columnSection {
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.columnContainer {
|
||||
flex: 1 1 200px;
|
||||
min-width: 200px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
margin: auto;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
li, p {
|
||||
font-family: 'opensans', sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1150px) {
|
||||
.columnContainer, .columnSection {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 690px) {
|
||||
.columnContainer {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<img id="header-img" src="https://beta.alttd.com/public/img/logos/logo.png" alt="The Altitude Minecraft Server" height="159"
|
||||
width="275">
|
||||
<h1 style="text-align: center;" th:text="'Appeal by ' + ${appeal.username}">Appeal by Username</h1>
|
||||
<section class="columnSection">
|
||||
<div class="columnContainer">
|
||||
<div>
|
||||
<h2>User information</h2>
|
||||
<ul>
|
||||
<li><strong>Username:</strong> <span th:text="${appeal.username}">username</span></li>
|
||||
<li><strong>UUID:</strong> <span th:text="${appeal.uuid}">uuid</span></li>
|
||||
<li><strong>Email:</strong> <span th:text="${appeal.email}">email</span></li>
|
||||
<li><strong>Submitted at:</strong> <span th:text="${createdAt}">date</span></li>
|
||||
<li><strong>Reason:</strong> <span th:text="${history.reason}">reason</span></li>
|
||||
<li><strong>Active:</strong> <span th:text="${active}">unknown</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columnContainer">
|
||||
<div>
|
||||
<h2>Appeal:</h2>
|
||||
<p th:text="${appeal.reason}">Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
|
||||
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Staff Application</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'minecraft-title';
|
||||
src: url('https://beta.alttd.com/public/fonts/minecraft-title.ttf') format('truetype'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-title.eot') format('embedded-opentype'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-title.svg') format('svg'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-title.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'minecraft-text';
|
||||
src: url('https://beta.alttd.com/public/fonts/minecraft-text.ttf') format('truetype'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-text.eot') format('embedded-opentype'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-text.svg') format('svg'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-text.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'opensans';
|
||||
src: url('https://beta.alttd.com/public/fonts/opensans.ttf') format('truetype'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans.eot') format('embedded-opentype'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans.svg') format('svg'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'opensans-bold';
|
||||
src: url('https://beta.alttd.com/public/fonts/opensans-bold.ttf') format('truetype'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans-bold.eot') format('embedded-opentype'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans-bold.svg') format('svg'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans-bold.woff') format('woff');
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'minecraft-title', sans-serif;
|
||||
}
|
||||
|
||||
.columnSection {
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.columnContainer {
|
||||
flex: 1 1 200px;
|
||||
min-width: 200px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
margin: auto;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
li, p {
|
||||
font-family: 'opensans', sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1150px) {
|
||||
.columnContainer, .columnSection {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 690px) {
|
||||
.columnContainer {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<img id="header-img" src="https://beta.alttd.com/public/img/logos/logo.png" alt="The Altitude Minecraft Server" height="159" width="275">
|
||||
<h1 style="text-align: center;" th:text="'Staff application by ' + ${application.discordUsername}">Staff application</h1>
|
||||
|
||||
<section class="columnSection">
|
||||
<div class="columnContainer">
|
||||
<div>
|
||||
<h2>Applicant</h2>
|
||||
<ul>
|
||||
<li><strong>Username:</strong> <span th:text="${username}">uuid</span></li>
|
||||
<li><strong>Email:</strong> <span th:text="${application.email}">email</span></li>
|
||||
<li><strong>Discord:</strong> <span th:text="${application.discordUsername}">discord</span></li>
|
||||
<li><strong>Age:</strong> <span th:text="${application.age}">age</span></li>
|
||||
<li><strong>Pronouns:</strong> <span th:text="${application.pronouns}">pronouns</span></li>
|
||||
<li><strong>Join date:</strong> <span th:text="${application.joinDate}">date</span></li>
|
||||
<li><strong>Submitted at:</strong> <span th:text="${createdAt}">date</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columnContainer">
|
||||
<div>
|
||||
<h2>Availability</h2>
|
||||
<ul>
|
||||
<li><strong>Days:</strong> <span th:text="${application.availableDays}">days</span></li>
|
||||
<li><strong>Times:</strong> <span th:text="${application.availableTimes}">times</span></li>
|
||||
<li><strong>Weekly playtime:</strong> <span th:text="${application.weeklyPlaytime}">0</span> hours</li>
|
||||
</ul>
|
||||
|
||||
<h2>Experience</h2>
|
||||
<p><strong>Previous:</strong><br><span th:text="${application.previousExperience}">previous experience</span></p>
|
||||
<p><strong>Plugins:</strong><br><span th:text="${application.pluginExperience}">plugin experience</span></p>
|
||||
<p><strong>Expectations:</strong><br><span th:text="${application.moderatorExpectations}">moderator expectations</span></p>
|
||||
|
||||
<div th:if="${application.additionalInfo} != null and ${application.additionalInfo} != ''">
|
||||
<h2>Additional info</h2>
|
||||
<p th:text="${application.additionalInfo}">additional info</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,7 +6,9 @@ import lombok.Getter;
|
||||
public enum Databases {
|
||||
DEFAULT("web_db"),
|
||||
LUCK_PERMS("luckperms"),
|
||||
LITE_BANS("litebans");
|
||||
LITE_BANS("litebans"),
|
||||
DISCORD("discordLink"),
|
||||
VOTING_PLUGIN("votingplugin");
|
||||
|
||||
private final String internalName;
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.alttd.altitudeweb.database.discord;
|
||||
|
||||
public record OutputChannel(long guild, String outputType, long channel, String channelType) {
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.alttd.altitudeweb.database.discord;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface OutputChannelMapper {
|
||||
@Select("""
|
||||
SELECT guild, output_type, channel, channel_type
|
||||
FROM output_channels
|
||||
WHERE output_type = #{outputType}
|
||||
""")
|
||||
List<OutputChannel> getChannelsWithOutputType(@Param("outputType") String outputType);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.alttd.altitudeweb.database.litebans;
|
||||
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public interface EditHistoryMapper {
|
||||
|
||||
@Update("""
|
||||
UPDATE ${table_name}
|
||||
SET reason = #{reason}
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
int updateReason(@Param("table_name") String tableName,
|
||||
@Param("id") int id,
|
||||
@Param("reason") String reason);
|
||||
|
||||
@Update("""
|
||||
UPDATE ${table_name}
|
||||
SET until = #{until}
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
int updateUntil(@Param("table_name") String tableName,
|
||||
@Param("id") int id,
|
||||
@Param("until") Long until);
|
||||
|
||||
@Delete("""
|
||||
DELETE FROM ${table_name}
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
int deletePunishment(@Param("table_name") String tableName,
|
||||
@Param("id") int id);
|
||||
|
||||
default int setReason(@NotNull HistoryType type, int id, String reason) {
|
||||
return switch (type) {
|
||||
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
|
||||
case BAN -> updateReason("litebans_bans", id, reason);
|
||||
case MUTE -> updateReason("litebans_mutes", id, reason);
|
||||
case KICK -> updateReason("litebans_kicks", id, reason);
|
||||
case WARN -> updateReason("litebans_warnings", id, reason);
|
||||
};
|
||||
}
|
||||
|
||||
default int setUntil(@NotNull HistoryType type, int id, Long until) {
|
||||
return switch (type) {
|
||||
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
|
||||
case BAN -> updateUntil("litebans_bans", id, until);
|
||||
case MUTE -> updateUntil("litebans_mutes", id, until);
|
||||
case KICK -> throw new IllegalArgumentException("KICK has no until");
|
||||
case WARN -> throw new IllegalArgumentException("WARN has no until");
|
||||
};
|
||||
}
|
||||
|
||||
default int remove(@NotNull HistoryType type, int id) {
|
||||
return switch (type) {
|
||||
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
|
||||
case BAN -> deletePunishment("litebans_bans", id);
|
||||
case MUTE -> deletePunishment("litebans_mutes", id);
|
||||
case KICK -> deletePunishment("litebans_kicks", id);
|
||||
case WARN -> deletePunishment("litebans_warnings", id);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,17 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public interface RecentNamesMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT name AS username
|
||||
FROM litebans_history
|
||||
WHERE uuid = #{uuid}
|
||||
ORDER BY date DESC
|
||||
LIMIT 1;
|
||||
""")
|
||||
String getUsername(@Param("uuid") String uuid);
|
||||
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT user_lookup.name AS punished_name
|
||||
FROM ${tableName} AS punishment
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.alttd.altitudeweb.database.luckperms;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
public interface UUIDUsernameMapper {
|
||||
@Select("""
|
||||
SELECT username
|
||||
FROM luckperms_players
|
||||
WHERE uuid = #{uuid}
|
||||
""")
|
||||
String getUsernameFromUUID(@Param("uuid") String uuid);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.alttd.altitudeweb.database.votingplugin;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface VotingPluginUsersMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT
|
||||
LastVotes as lastVotes,
|
||||
BestDayVoteStreak as bestDailyStreak,
|
||||
BestWeekVoteStreak as bestWeeklyStreak,
|
||||
BestMonthVoteStreak as bestMonthlyStreak,
|
||||
DayVoteStreak as dailyStreak,
|
||||
WeekVoteStreak as weeklyStreak,
|
||||
MonthVoteStreak as monthlyStreak,
|
||||
DailyTotal as totalVotesToday,
|
||||
WeeklyTotal as totalVotesThisWeek,
|
||||
MonthTotal as totalVotesThisMonth,
|
||||
AllTimeTotal as totalVotesAllTime
|
||||
FROM votingplugin.votingplugin_users
|
||||
WHERE uuid = #{uuid}
|
||||
""")
|
||||
Optional<VotingStatsRow> getStatsByUuid(@Param("uuid") UUID uuid);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.alttd.altitudeweb.database.votingplugin;
|
||||
|
||||
public record VotingStatsRow(
|
||||
String lastVotes,
|
||||
Integer bestDailyStreak,
|
||||
Integer bestWeeklyStreak,
|
||||
Integer bestMonthlyStreak,
|
||||
Integer dailyStreak,
|
||||
Integer weeklyStreak,
|
||||
Integer monthlyStreak,
|
||||
Integer totalVotesToday,
|
||||
Integer totalVotesThisWeek,
|
||||
Integer totalVotesThisMonth,
|
||||
Integer totalVotesAllTime
|
||||
) { }
|
||||
@@ -4,7 +4,12 @@ import org.apache.ibatis.annotations.*;
|
||||
|
||||
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();
|
||||
|
||||
@Insert("""
|
||||
|
||||
@@ -5,12 +5,13 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PrivilegedUser {
|
||||
private int id;
|
||||
private String uuid;
|
||||
private Integer id;
|
||||
private UUID uuid;
|
||||
private List<String> permissions;
|
||||
}
|
||||
|
||||
+14
-5
@@ -1,21 +1,23 @@
|
||||
package com.alttd.altitudeweb.database.web_db;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface PrivilegedUserMapper {
|
||||
|
||||
/**
|
||||
* Retrieves a user by their UUID along with their permissions
|
||||
* @param uuid The UUID of the user to retrieve
|
||||
* @return The PrivilegedUser with their permissions, or null if not found
|
||||
* @return The optional PrivilegedUser with their permissions
|
||||
*/
|
||||
@Select("""
|
||||
SELECT privileged_users.id, privileged_users.uuid, privileges.privileges as permission
|
||||
SELECT id, uuid
|
||||
FROM privileged_users
|
||||
LEFT JOIN privileges ON privileged_users.id = privileges.user_id
|
||||
WHERE privileged_users.uuid = #{uuid}
|
||||
WHERE uuid = #{uuid}
|
||||
""")
|
||||
@Results({
|
||||
@Result(property = "id", column = "id"),
|
||||
@@ -23,7 +25,7 @@ public interface PrivilegedUserMapper {
|
||||
@Result(property = "permissions", column = "id", javaType = List.class,
|
||||
many = @Many(select = "getPermissionsForUser"))
|
||||
})
|
||||
PrivilegedUser getUserByUuid(@Param("uuid") String uuid);
|
||||
Optional<PrivilegedUser> getUserByUuid(@Param("uuid") UUID uuid);
|
||||
|
||||
/**
|
||||
* Retrieves all privileged users with their permissions
|
||||
@@ -99,4 +101,11 @@ public interface PrivilegedUserMapper {
|
||||
WHERE user_id = #{userId} AND privileges = #{permission}
|
||||
""")
|
||||
int removePermissionFromUser(@Param("userId") int userId, @Param("permission") String permission);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO privileged_users (uuid)
|
||||
VALUES (#{uuid})
|
||||
""")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||
int createPrivilegedUser(UUID uuid);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@ package com.alttd.altitudeweb.database.web_db;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
public interface SettingsMapper {
|
||||
@Select("SELECT host, port, name, username, password FROM db_connection_settings WHERE name = #{database}")
|
||||
@Select("SELECT host, port, name, username, password FROM db_connection_settings WHERE internal_name = #{database}")
|
||||
DatabaseSettings getSettings(String database);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.alttd.altitudeweb.database.web_db.forms;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record Appeal(
|
||||
UUID id,
|
||||
UUID uuid,
|
||||
String historyType,
|
||||
Integer historyId,
|
||||
String username,
|
||||
String reason,
|
||||
Instant createdAt,
|
||||
Instant sendAt,
|
||||
String email,
|
||||
Long assignedTo
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.alttd.altitudeweb.database.web_db.forms;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AppealMapper {
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO appeals (uuid, username, historyType, historyId, reason, created_at, send_at, e_mail, assigned_to)
|
||||
VALUES (#{uuid}, #{username}, #{historyType}, #{historyId}, #{reason}, #{createdAt}, #{sendAt}, #{email}, #{assignedTo})
|
||||
""")
|
||||
void createAppeal(Appeal appeal);
|
||||
|
||||
@Select("""
|
||||
SELECT id, uuid, historyType, historyId, reason, created_at AS createdAt, send_at AS sendAt, e_mail AS email, assigned_to AS assignedTo
|
||||
FROM appeals
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
Appeal getAppealById(int id);
|
||||
|
||||
@Select("""
|
||||
SELECT id, uuid, historyType, historyId, reason, created_at AS createdAt, send_at AS sendAt, e_mail AS email, assigned_to AS assignedTo
|
||||
FROM appeals
|
||||
WHERE uuid = #{uuid}
|
||||
""")
|
||||
List<Appeal> getAppealsByUuid(String uuid);
|
||||
|
||||
@Update("""
|
||||
UPDATE appeals SET send_at = NOW()
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
void markAppealAsSent(UUID id);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.alttd.altitudeweb.database.web_db.forms;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record StaffApplication(
|
||||
UUID id,
|
||||
UUID uuid,
|
||||
String email,
|
||||
Integer age,
|
||||
String discordUsername,
|
||||
Boolean meetsRequirements,
|
||||
String pronouns,
|
||||
LocalDate joinDate,
|
||||
Integer weeklyPlaytime,
|
||||
String availableDays,
|
||||
String availableTimes,
|
||||
String previousExperience,
|
||||
String pluginExperience,
|
||||
String moderatorExpectations,
|
||||
String additionalInfo,
|
||||
Instant createdAt,
|
||||
Instant sendAt,
|
||||
Long assignedTo
|
||||
) {
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.alttd.altitudeweb.database.web_db.forms;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface StaffApplicationMapper {
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO staff_applications (
|
||||
id, uuid, email, age, discord_username, meets_requirements, pronouns, join_date,
|
||||
weekly_playtime, available_days, available_times, previous_experience, plugin_experience,
|
||||
moderator_expectations, additional_info, created_at, send_at, assigned_to
|
||||
) VALUES (
|
||||
#{id}, #{uuid}, #{email}, #{age}, #{discordUsername}, #{meetsRequirements}, #{pronouns}, #{joinDate},
|
||||
#{weeklyPlaytime},
|
||||
#{availableDays},
|
||||
#{availableTimes}, #{previousExperience}, #{pluginExperience},
|
||||
#{moderatorExpectations}, #{additionalInfo}, #{createdAt}, #{sendAt}, #{assignedTo}
|
||||
)
|
||||
""")
|
||||
void insert(StaffApplication application);
|
||||
|
||||
@Update("""
|
||||
UPDATE staff_applications SET send_at = NOW()
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
void markAsSent(@Param("id") UUID id);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.alttd.altitudeweb.database.web_db.mail;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record EmailVerification(
|
||||
UUID id,
|
||||
UUID userUuid,
|
||||
String email,
|
||||
String verificationCode,
|
||||
boolean verified,
|
||||
Instant createdAt,
|
||||
Instant verifiedAt,
|
||||
Instant lastSentAt
|
||||
) {
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.alttd.altitudeweb.database.web_db.mail;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface EmailVerificationMapper {
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO user_emails (id, user_uuid, email, verification_code, verified, created_at, last_sent_at)
|
||||
VALUES (#{id}, #{userUuid}, #{email}, #{verificationCode}, #{verified}, #{createdAt}, #{lastSentAt})
|
||||
""")
|
||||
void insert(EmailVerification emailVerification);
|
||||
|
||||
@Select("""
|
||||
SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified,
|
||||
created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt
|
||||
FROM user_emails
|
||||
WHERE user_uuid = #{userUuid} AND email = #{email}
|
||||
""")
|
||||
EmailVerification findByUserAndEmail(@Param("userUuid") UUID userUuid, @Param("email") String email);
|
||||
|
||||
@Select("""
|
||||
SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified,
|
||||
created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt
|
||||
FROM user_emails
|
||||
WHERE user_uuid = #{userUuid} AND verification_code = #{code}
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""")
|
||||
EmailVerification findByUserAndCode(@Param("userUuid") UUID userUuid, @Param("code") String code);
|
||||
|
||||
@Select("""
|
||||
SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified,
|
||||
created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt
|
||||
FROM user_emails
|
||||
WHERE user_uuid = #{userUuid}
|
||||
ORDER BY created_at ASC
|
||||
""")
|
||||
java.util.List<EmailVerification> findAllByUser(@Param("userUuid") UUID userUuid);
|
||||
|
||||
@Update("""
|
||||
UPDATE user_emails SET verified = 1, verified_at = #{verifiedAt}
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
void markVerified(@Param("id") UUID id, @Param("verifiedAt") Instant verifiedAt);
|
||||
|
||||
@Update("""
|
||||
UPDATE user_emails SET verification_code = #{code}, last_sent_at = #{lastSentAt}, verified = 0, verified_at = NULL
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
void updateCodeAndLastSent(@Param("id") UUID id, @Param("code") String code, @Param("lastSentAt") Instant lastSentAt);
|
||||
|
||||
@Delete("""
|
||||
DELETE FROM user_emails WHERE user_uuid = #{userUuid} AND email = #{email}
|
||||
""")
|
||||
void deleteByUserAndEmail(@Param("userUuid") UUID userUuid, @Param("email") String email);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.alttd.altitudeweb.setup;
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.web_db.DatabaseSettings;
|
||||
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
|
||||
import com.alttd.altitudeweb.type_handler.UUIDTypeHandler;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.datasource.pooled.PooledDataSource;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
@@ -11,9 +12,12 @@ import org.apache.ibatis.session.SqlSession;
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@Slf4j
|
||||
@@ -33,6 +37,8 @@ public class Connection {
|
||||
InitializeWebDb.init();
|
||||
InitializeLiteBans.init();
|
||||
InitializeLuckPerms.init();
|
||||
InitializeDiscord.init();
|
||||
InitializeVotingPlugin.init();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
@@ -54,25 +60,46 @@ public class Connection {
|
||||
if (database == Databases.DEFAULT) {
|
||||
return loadDefaultDatabase(addMappers);
|
||||
}
|
||||
CompletableFuture<DatabaseSettings> settingsFuture = new CompletableFuture<>();
|
||||
getConnection(Databases.DEFAULT, (mapper -> mapper.addMapper(SettingsMapper.class))).thenApply(connection -> {
|
||||
|
||||
log.debug("Loading settings for database {}", database.getInternalName());
|
||||
CompletableFuture<DatabaseSettings> settingsFuture = new CompletableFuture<>();
|
||||
|
||||
getConnection(Databases.DEFAULT, (mapper -> mapper.addMapper(SettingsMapper.class))).thenApply(connection -> {
|
||||
connection.runQuery(session -> {
|
||||
log.debug("Running query to load settings for database");
|
||||
try {
|
||||
log.debug("Running query to load settings for database {}", database.getInternalName());
|
||||
DatabaseSettings loadedSettings = session.getMapper(SettingsMapper.class).getSettings(database.getInternalName());
|
||||
|
||||
if (loadedSettings == null) {
|
||||
log.error("Failed to load settings for database {}", database.getInternalName());
|
||||
}
|
||||
log.debug("Loaded settings {}", loadedSettings);
|
||||
log.error("Failed to load settings for database {}. No settings found in db_connection_settings table.",
|
||||
database.getInternalName());
|
||||
settingsFuture.completeExceptionally(new IllegalStateException(
|
||||
"Database settings for " + database.getInternalName() + " not found in db_connection_settings table"));
|
||||
} else {
|
||||
log.debug("Loaded settings for database {}: host={}, port={}, name={}",
|
||||
database.getInternalName(), loadedSettings.host(), loadedSettings.port(), loadedSettings.name());
|
||||
settingsFuture.complete(loadedSettings);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error occurred while loading database settings for {}", database.getInternalName(), e);
|
||||
settingsFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}).exceptionally(ex -> {
|
||||
log.error("Failed to access DEFAULT database to load settings for {}", database.getInternalName(), ex);
|
||||
settingsFuture.completeExceptionally(ex);
|
||||
return null;
|
||||
});
|
||||
|
||||
return settingsFuture.thenApply(loadedSettings -> {
|
||||
log.debug("Storing connection for database {}", database.getInternalName());
|
||||
Connection connection = new Connection(loadedSettings, addMappers);
|
||||
connections.put(database, connection);
|
||||
return connection;
|
||||
}).exceptionally(ex -> {
|
||||
log.error("Failed to create connection for database {}", database.getInternalName(), ex);
|
||||
throw new CompletionException("Failed to initialize database connection for " + database.getInternalName(), ex);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,26 +124,66 @@ public class Connection {
|
||||
sqlSessionFactory = createSqlSessionFactory(settings, addMappers);
|
||||
}
|
||||
|
||||
try (SqlSession session = sqlSessionFactory.openSession()) {
|
||||
SqlSession session = null;
|
||||
try {
|
||||
session = sqlSessionFactory.openSession();
|
||||
consumer.accept(session);
|
||||
session.commit();
|
||||
} catch (Exception e) {
|
||||
if (session != null) {
|
||||
session.rollback();
|
||||
}
|
||||
log.error("Failed to run query", e);
|
||||
} finally {
|
||||
if (session != null) {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private SqlSessionFactory createSqlSessionFactory(DatabaseSettings settings, AddMappers addMappers) {
|
||||
PooledDataSource dataSource = new PooledDataSource();
|
||||
dataSource.setDriver("com.mysql.cj.jdbc.Driver");
|
||||
dataSource.setUrl(String.format("jdbc:mysql://%s:%d/%s", settings.host(),
|
||||
settings.port(), settings.name()));
|
||||
dataSource.setUsername(settings.username());
|
||||
dataSource.setPassword(settings.password());
|
||||
Environment environment = new Environment("production", new JdbcTransactionFactory(), dataSource);
|
||||
Configuration configuration = new Configuration(environment);
|
||||
try {
|
||||
Configuration configuration = getConfiguration(settings);
|
||||
configuration.getTypeHandlerRegistry().register(UUID.class, UUIDTypeHandler.class);
|
||||
addMappers.apply(configuration);
|
||||
|
||||
return new SqlSessionFactoryBuilder().build(configuration);
|
||||
} catch (Exception e) {
|
||||
log.error("""
|
||||
Failed to create sql session factory with
|
||||
\thost {}
|
||||
\tport: {}
|
||||
\tname: {}
|
||||
\tusername: {}
|
||||
""", settings.host(), settings.port(), settings.name(), settings.username(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private static @NotNull Configuration getConfiguration(DatabaseSettings settings) {
|
||||
PooledDataSource dataSource = new PooledDataSource();
|
||||
dataSource.setDriver("com.mysql.cj.jdbc.Driver");
|
||||
|
||||
String url = String.format(
|
||||
"jdbc:mysql://%s:%d/%s?useSSL=true&tcpKeepAlive=true&socketTimeout=60000&connectTimeout=10000&autoReconnect=false&useUnicode=true&characterEncoding=utf8",
|
||||
settings.host(),
|
||||
settings.port(),
|
||||
settings.name()
|
||||
);
|
||||
dataSource.setUrl(url);
|
||||
dataSource.setUsername(settings.username());
|
||||
dataSource.setPassword(settings.password());
|
||||
|
||||
dataSource.setPoolMaximumActiveConnections(10);
|
||||
dataSource.setPoolMaximumIdleConnections(5);
|
||||
dataSource.setPoolTimeToWait(20000);
|
||||
dataSource.setPoolPingEnabled(true);
|
||||
dataSource.setPoolPingQuery("SELECT 1");
|
||||
dataSource.setPoolPingConnectionsNotUsedFor(300000); // 5 min
|
||||
|
||||
Environment environment = new Environment("production", new JdbcTransactionFactory(), dataSource);
|
||||
return new Configuration(environment);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.alttd.altitudeweb.setup;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
|
||||
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class InitializeDiscord {
|
||||
|
||||
protected static void init() {
|
||||
log.info("Initializing Discord");
|
||||
Connection.getConnection(Databases.DISCORD, (configuration) -> {
|
||||
configuration.addMapper(OutputChannelMapper.class);
|
||||
}).join();
|
||||
log.debug("Initialized Discord");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public class InitializeLiteBans {
|
||||
configuration.addMapper(UUIDHistoryMapper.class);
|
||||
configuration.addMapper(HistoryCountMapper.class);
|
||||
configuration.addMapper(IdHistoryMapper.class);
|
||||
configuration.addMapper(EditHistoryMapper.class);
|
||||
}).join()
|
||||
.runQuery(sqlSession -> {
|
||||
createAllPunishmentsView(sqlSession);
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.alttd.altitudeweb.setup;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
|
||||
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@@ -11,6 +12,7 @@ public class InitializeLuckPerms {
|
||||
log.info("Initializing LuckPerms");
|
||||
Connection.getConnection(Databases.LUCK_PERMS, (configuration) -> {
|
||||
configuration.addMapper(TeamMemberMapper.class);
|
||||
configuration.addMapper(UUIDUsernameMapper.class);
|
||||
}).join();
|
||||
log.debug("Initialized LuckPerms");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.alttd.altitudeweb.setup;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class InitializeVotingPlugin {
|
||||
|
||||
protected static void init() {
|
||||
log.info("Initializing VotingPlugin");
|
||||
Connection.getConnection(Databases.VOTING_PLUGIN, (configuration) -> {
|
||||
configuration.addMapper(VotingPluginUsersMapper.class);
|
||||
}).join();
|
||||
log.debug("Initialized VotingPlugin");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,11 @@ package com.alttd.altitudeweb.setup;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
@@ -18,12 +22,19 @@ public class InitializeWebDb {
|
||||
Connection.getConnection(Databases.DEFAULT, (configuration) -> {
|
||||
configuration.addMapper(SettingsMapper.class);
|
||||
configuration.addMapper(KeyPairMapper.class);
|
||||
configuration.addMapper(PrivilegedUserMapper.class);
|
||||
configuration.addMapper(AppealMapper.class);
|
||||
configuration.addMapper(StaffApplicationMapper.class);
|
||||
configuration.addMapper(EmailVerificationMapper.class);
|
||||
}).join()
|
||||
.runQuery(SqlSession -> {
|
||||
createSettingsTable(SqlSession);
|
||||
createKeyTable(SqlSession);
|
||||
createPrivilegedUsersTable(SqlSession);
|
||||
createPrivilegesTable(SqlSession);
|
||||
.runQuery(sqlSession -> {
|
||||
createSettingsTable(sqlSession);
|
||||
createKeyTable(sqlSession);
|
||||
createPrivilegedUsersTable(sqlSession);
|
||||
createPrivilegesTable(sqlSession);
|
||||
createAppealTable(sqlSession);
|
||||
createStaffApplicationsTable(sqlSession);
|
||||
createUserEmailsTable(sqlSession);
|
||||
});
|
||||
log.debug("Initialized WebDb");
|
||||
}
|
||||
@@ -68,7 +79,7 @@ public class InitializeWebDb {
|
||||
String query = """
|
||||
CREATE TABLE IF NOT EXISTS privileged_users (
|
||||
id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid VARCHAR(36) NOT NULL
|
||||
uuid UUID UNIQUE NOT NULL
|
||||
);
|
||||
""";
|
||||
try (Statement statement = sqlSession.getConnection().createStatement()) {
|
||||
@@ -97,4 +108,79 @@ public class InitializeWebDb {
|
||||
}
|
||||
}
|
||||
|
||||
private static void createUserEmailsTable(@NotNull SqlSession sqlSession) {
|
||||
String query = """
|
||||
CREATE TABLE IF NOT EXISTS user_emails (
|
||||
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
|
||||
user_uuid UUID NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
verification_code VARCHAR(16) NOT NULL,
|
||||
verified BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
verified_at TIMESTAMP NULL,
|
||||
last_sent_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (user_uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
""";
|
||||
try (Statement statement = sqlSession.getConnection().createStatement()) {
|
||||
statement.execute(query);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createStaffApplicationsTable(@NotNull SqlSession sqlSession) {
|
||||
String query = """
|
||||
CREATE TABLE IF NOT EXISTS staff_applications (
|
||||
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
|
||||
uuid UUID NOT NULL,
|
||||
email VARCHAR(320) NOT NULL,
|
||||
age INT NOT NULL,
|
||||
discord_username VARCHAR(32) NOT NULL,
|
||||
meets_requirements BOOLEAN NOT NULL,
|
||||
pronouns VARCHAR(32) NULL,
|
||||
join_date DATE NOT NULL,
|
||||
weekly_playtime INT NOT NULL,
|
||||
available_days TEXT NOT NULL,
|
||||
available_times TEXT NOT NULL,
|
||||
previous_experience TEXT NOT NULL,
|
||||
plugin_experience TEXT NOT NULL,
|
||||
moderator_expectations TEXT NOT NULL,
|
||||
additional_info TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
send_at TIMESTAMP NULL,
|
||||
assigned_to BIGINT UNSIGNED NULL,
|
||||
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
""";
|
||||
try (Statement statement = sqlSession.getConnection().createStatement()) {
|
||||
statement.execute(query);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createAppealTable(@NotNull SqlSession sqlSession) {
|
||||
String query = """
|
||||
CREATE TABLE IF NOT EXISTS appeals (
|
||||
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
|
||||
uuid UUID NOT NULL,
|
||||
historyType VARCHAR(16) NOT NULL,
|
||||
historyId BIGINT UNSIGNED NOT NULL,
|
||||
username VARCHAR(16) NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
send_at TIMESTAMP NULL,
|
||||
e_mail TEXT NOT NULL,
|
||||
assigned_to BIGINT UNSIGNED NULL,
|
||||
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
""";
|
||||
try (Statement statement = sqlSession.getConnection().createStatement()) {
|
||||
statement.execute(query);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
plugins {
|
||||
id("java")
|
||||
}
|
||||
|
||||
group = "com.alttd.webinterface"
|
||||
version = "unspecified"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(platform("org.junit:junit-bom:5.10.0"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
// JDA
|
||||
implementation("net.dv8tion:JDA:6.0.0-rc.2") {
|
||||
exclude("opus-java") // exclude audio
|
||||
exclude("tink") // exclude audio
|
||||
}
|
||||
compileOnly("org.projectlombok:lombok:1.18.38")
|
||||
annotationProcessor("org.projectlombok:lombok:1.18.38")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.alttd.webinterface;
|
||||
|
||||
import com.alttd.webinterface.bot.DiscordBotInstance;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class DiscordBot {
|
||||
|
||||
public static void main(String[] args) {
|
||||
String discordToken = System.getProperty("DISCORD_TOKEN");
|
||||
if (discordToken == null) {
|
||||
log.error("Discord token not found, put it in the DISCORD_TOKEN environment variable");
|
||||
System.exit(1);
|
||||
}
|
||||
DiscordBotInstance discordBotInstance = new DiscordBotInstance();
|
||||
discordBotInstance.start(discordToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.alttd.webinterface.bot;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.JDABuilder;
|
||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||
|
||||
@Slf4j
|
||||
public class DiscordBotInstance {
|
||||
|
||||
@Getter
|
||||
private JDA jda;
|
||||
private volatile boolean ready = false;
|
||||
|
||||
public synchronized void start(String token) {
|
||||
if (jda != null) {
|
||||
return;
|
||||
}
|
||||
jda = JDABuilder.createDefault(token,
|
||||
GatewayIntent.GUILD_MEMBERS,
|
||||
GatewayIntent.GUILD_PRESENCES,
|
||||
GatewayIntent.GUILD_MESSAGES,
|
||||
GatewayIntent.MESSAGE_CONTENT)
|
||||
.addEventListeners(new ReadyListener(() -> {
|
||||
ready = true;
|
||||
}))
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean isReady() {
|
||||
return ready && jda != null && jda.getStatus() == JDA.Status.CONNECTED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.alttd.webinterface.bot;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.dv8tion.jda.api.events.session.ReadyEvent;
|
||||
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ReadyListener extends ListenerAdapter {
|
||||
|
||||
private final Runnable onReadyCallback;
|
||||
|
||||
@Override
|
||||
public void onReady(@NotNull ReadyEvent event) {
|
||||
log.info("JDA is ready. Guilds loaded: {}", event.getJDA().getGuilds().size());
|
||||
if (onReadyCallback != null) {
|
||||
try {
|
||||
onReadyCallback.run();
|
||||
} catch (Exception e) {
|
||||
log.error("Error running onReady callback", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.alttd.webinterface.send_message;
|
||||
|
||||
import com.alttd.webinterface.bot.DiscordBotInstance;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.dv8tion.jda.api.EmbedBuilder;
|
||||
import net.dv8tion.jda.api.entities.MessageEmbed;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||
|
||||
import java.awt.*;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
public class DiscordSender {
|
||||
|
||||
private static final DiscordSender INSTANCE = new DiscordSender();
|
||||
|
||||
private final DiscordBotInstance botInstance = new DiscordBotInstance();
|
||||
|
||||
private DiscordSender() {}
|
||||
|
||||
public static DiscordSender getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private void ensureStarted() {
|
||||
if (botInstance.getJda() != null) return;
|
||||
String token = Optional.ofNullable(System.getenv("DISCORD_TOKEN"))
|
||||
.orElse(System.getProperty("DISCORD_TOKEN"));
|
||||
if (token == null || token.isBlank()) {
|
||||
log.error("Discord token not found. Set DISCORD_TOKEN as an environment variable or system property.");
|
||||
return;
|
||||
}
|
||||
botInstance.start(token);
|
||||
}
|
||||
|
||||
public void sendMessageToChannels(List<Long> channelIds, String message) {
|
||||
ensureStarted();
|
||||
if (botInstance.getJda() == null) {
|
||||
log.error("JDA not initialized; cannot send Discord message.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!botInstance.isReady()) {
|
||||
botInstance.getJda().awaitReady();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
log.warn("Error while waiting for JDA ready state", e);
|
||||
}
|
||||
|
||||
channelIds.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.forEach(id -> {
|
||||
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id);
|
||||
if (channel == null) {
|
||||
log.warn("TextChannel with id {} not found", id);
|
||||
return;
|
||||
}
|
||||
channel.sendMessage(message).queue(
|
||||
success -> log.debug("Sent message to channel {}", id),
|
||||
error -> log.error("Failed sending message to channel {}", id, error)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public void sendEmbedToChannels(List<Long> channelIds, String title, String description, List<EmbedField> fields,
|
||||
Integer colorRgb, Instant timestamp, String footer) {
|
||||
ensureStarted();
|
||||
if (botInstance.getJda() == null) {
|
||||
log.error("JDA not initialized; cannot send Discord embed.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!botInstance.isReady()) {
|
||||
botInstance.getJda().awaitReady();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
log.warn("Error while waiting for JDA ready state", e);
|
||||
}
|
||||
|
||||
EmbedBuilder eb = new EmbedBuilder();
|
||||
if (title != null && !title.isBlank()) eb.setTitle(title);
|
||||
if (description != null && !description.isBlank()) eb.setDescription(description);
|
||||
if (colorRgb != null) eb.setColor(new Color(colorRgb)); else eb.setColor(new Color(0xFF8C00)); // default orange
|
||||
eb.setTimestamp(timestamp != null ? timestamp : Instant.now());
|
||||
if (footer != null && !footer.isBlank()) eb.setFooter(footer);
|
||||
|
||||
if (fields != null) {
|
||||
for (EmbedField f : fields) {
|
||||
if (f == null) continue;
|
||||
String name = f.getName() == null ? "" : f.getName();
|
||||
String value = f.getValue() == null ? "" : f.getValue();
|
||||
// JDA field value max is 1024; truncate to be safe
|
||||
if (value.length() > 1024) value = value.substring(0, 1021) + "...";
|
||||
eb.addField(new MessageEmbed.Field(name, value, f.isInline()));
|
||||
}
|
||||
}
|
||||
|
||||
MessageEmbed embed = eb.build();
|
||||
|
||||
channelIds.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.forEach(id -> {
|
||||
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id);
|
||||
if (channel == null) {
|
||||
log.warn("TextChannel with id {} not found", id);
|
||||
return;
|
||||
}
|
||||
channel.sendMessageEmbeds(embed).queue(
|
||||
success -> log.debug("Sent embed to channel {}", id),
|
||||
error -> log.error("Failed sending embed to channel {}", id, error)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class EmbedField {
|
||||
private String name;
|
||||
private String value;
|
||||
private boolean inline;
|
||||
}
|
||||
}
|
||||
+40
-13
@@ -15,11 +15,12 @@
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"outputPath": "dist",
|
||||
"outputPath": {
|
||||
"base": "dist"
|
||||
},
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
@@ -34,7 +35,8 @@
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
"scripts": [],
|
||||
"browser": "src/main.ts"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -47,9 +49,7 @@
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true
|
||||
"namedChunks": false
|
||||
},
|
||||
"development": {
|
||||
"sourceMap": true,
|
||||
@@ -71,14 +71,15 @@
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true
|
||||
"namedChunks": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
@@ -90,10 +91,10 @@
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
@@ -119,5 +120,31 @@
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
},
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"type": "component"
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"type": "directive"
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"type": "service"
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:module": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"typeSeparator": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-26
@@ -8,8 +8,9 @@ plugins {
|
||||
|
||||
node {
|
||||
download.set(true)
|
||||
version.set("22.14.0")
|
||||
npmVersion.set("10.9.2")
|
||||
// Update to the version that's compatible with your environment requirements
|
||||
version.set("20.19.0")
|
||||
npmVersion.set("10.2.3") // A compatible npm version for Node.js 20.19.0
|
||||
workDir.set(file("${project.projectDir}/node"))
|
||||
npmWorkDir.set(file("${project.projectDir}/node"))
|
||||
}
|
||||
@@ -21,38 +22,37 @@ tasks.register<Delete>("cleanDist") {
|
||||
}
|
||||
|
||||
// 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'"
|
||||
group = "build"
|
||||
|
||||
doLast {
|
||||
// Use nodeCommand directly from the plugin
|
||||
project.exec {
|
||||
workingDir(project.projectDir)
|
||||
|
||||
// Use node's npm to ensure it works on all environments
|
||||
val nodeDir = "${project.projectDir}/node"
|
||||
// Determine which build script to run based on the OS
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
|
||||
if (isWindows) {
|
||||
val npmCmd = file(nodeDir).listFiles()?.find { it.name.startsWith("npm") && it.isDirectory }?.let {
|
||||
"${it.absolutePath}/npm.cmd"
|
||||
} ?: "$nodeDir/node_modules/npm/bin/npm.cmd"
|
||||
|
||||
commandLine(npmCmd, "run", "build:dev")
|
||||
} else {
|
||||
val npmExecutable = file(nodeDir).listFiles()?.find { it.name.startsWith("npm") && it.isDirectory }?.let {
|
||||
"${it.absolutePath}/bin/npm"
|
||||
} ?: "$nodeDir/node_modules/npm/bin/npm"
|
||||
|
||||
commandLine(npmExecutable, "run", "build:beta")
|
||||
}
|
||||
}
|
||||
}
|
||||
npmCommand.set(listOf("run", if (isWindows) "build:dev" else "build:beta"))
|
||||
|
||||
dependsOn("npmInstall")
|
||||
}
|
||||
|
||||
// Add a new task to check Node.js and npm versions
|
||||
tasks.register<com.github.gradle.node.task.NodeTask>("nodeVersionCheck") {
|
||||
description = "Check Node.js and npm versions"
|
||||
script.set(file("${projectDir}/node-version-check.js"))
|
||||
|
||||
doFirst {
|
||||
// Create a temporary script to check versions
|
||||
file("${projectDir}/node-version-check.js").writeText("""
|
||||
console.log('Node.js version:', process.version);
|
||||
console.log('npm version:', require('npm/package.json').version);
|
||||
console.log('Build command that would be used:', process.platform === 'win32' ? 'build:dev' : 'build:beta');
|
||||
""".trimIndent())
|
||||
}
|
||||
|
||||
doLast {
|
||||
// Clean up the temporary script
|
||||
delete("${projectDir}/node-version-check.js")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("assemble") {
|
||||
dependsOn("npmBuild")
|
||||
}
|
||||
|
||||
+17
-14
@@ -13,24 +13,27 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^19.2.18",
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/compiler": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"@angular/forms": "^19.2.0",
|
||||
"@angular/material": "^19.2.18",
|
||||
"@angular/platform-browser": "^19.2.0",
|
||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"ngx-cookie-service": "^19.1.2",
|
||||
"@angular/cdk": "^20.1.3",
|
||||
"@angular/common": "^20.1.0",
|
||||
"@angular/compiler": "^20.1.0",
|
||||
"@angular/core": "^20.1.0",
|
||||
"@angular/forms": "^20.1.0",
|
||||
"@angular/material": "^20.1.3",
|
||||
"@angular/platform-browser": "^20.1.0",
|
||||
"@angular/platform-browser-dynamic": "^20.1.0",
|
||||
"@angular/router": "^20.1.0",
|
||||
"@auth0/angular-jwt": "^5.2.0",
|
||||
"@types/three": "^0.177.0",
|
||||
"ngx-cookie-service": "^20.0.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"three": "^0.177.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.5",
|
||||
"@angular/cli": "^19.2.5",
|
||||
"@angular/compiler-cli": "^19.2.0",
|
||||
"@angular/build": "^20.1.0",
|
||||
"@angular/cli": "^20.1.0",
|
||||
"@angular/compiler-cli": "^20.1.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.6.0",
|
||||
"karma": "~6.4.0",
|
||||
@@ -38,6 +41,6 @@
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.7.2"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {Meta, Title} from '@angular/platform-browser';
|
||||
import {ALTITUDE_VERSION} from './constant';
|
||||
import {ALTITUDE_VERSION} from '@custom-types/constant';
|
||||
import {Router, RouterOutlet} from '@angular/router';
|
||||
import {FooterComponent} from './footer/footer.component';
|
||||
import {FooterComponent} from '@pages/footer/footer/footer.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -10,8 +10,8 @@ import {FooterComponent} from './footer/footer.component';
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
imports: [
|
||||
FooterComponent,
|
||||
RouterOutlet
|
||||
RouterOutlet,
|
||||
FooterComponent
|
||||
]
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
|
||||
@@ -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)]
|
||||
};
|
||||
@@ -1,110 +1,159 @@
|
||||
import {Routes} from '@angular/router';
|
||||
import {AuthGuard} from './guards/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
|
||||
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent)
|
||||
},
|
||||
{
|
||||
path: 'particles',
|
||||
loadComponent: () => import('./pages/particles/particles.component').then(m => m.ParticlesComponent),
|
||||
canActivate: [AuthGuard],
|
||||
data: {
|
||||
requiredAuthorizations: ['SCOPE_head_mod']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'map',
|
||||
loadComponent: () => import('./map/map.component').then(m => m.MapComponent)
|
||||
loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent)
|
||||
},
|
||||
{
|
||||
path: 'rules',
|
||||
loadComponent: () => import('./rules/rules.component').then(m => m.RulesComponent)
|
||||
loadComponent: () => import('./pages/reference/rules/rules.component').then(m => m.RulesComponent)
|
||||
},
|
||||
{
|
||||
path: 'vote',
|
||||
loadComponent: () => import('./vote/vote.component').then(m => m.VoteComponent)
|
||||
loadComponent: () => import('./pages/vote/vote.component').then(m => m.VoteComponent)
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
loadComponent: () => import('./about/about.component').then(m => m.AboutComponent)
|
||||
loadComponent: () => import('./pages/altitude/about/about.component').then(m => m.AboutComponent)
|
||||
},
|
||||
{
|
||||
path: 'socials',
|
||||
loadComponent: () => import('./socials/socials.component').then(m => m.SocialsComponent)
|
||||
loadComponent: () => import('./pages/altitude/socials/socials.component').then(m => m.SocialsComponent)
|
||||
},
|
||||
{
|
||||
path: 'team',
|
||||
loadComponent: () => import('./team/team.component').then(m => m.TeamComponent)
|
||||
loadComponent: () => import('./pages/altitude/team/team.component').then(m => m.TeamComponent)
|
||||
},
|
||||
{
|
||||
path: 'birthdays',
|
||||
loadComponent: () => import('./birthdays/birthdays.component').then(m => m.BirthdaysComponent)
|
||||
loadComponent: () => import('./pages/altitude/birthdays/birthdays.component').then(m => m.BirthdaysComponent)
|
||||
},
|
||||
{
|
||||
path: 'terms',
|
||||
loadComponent: () => import('./terms/terms.component').then(m => m.TermsComponent)
|
||||
loadComponent: () => import('./pages/footer/terms/terms.component').then(m => m.TermsComponent)
|
||||
},
|
||||
{
|
||||
path: 'privacy',
|
||||
loadComponent: () => import('./privacy/privacy.component').then(m => m.PrivacyComponent)
|
||||
loadComponent: () => import('./pages/footer/privacy/privacy.component').then(m => m.PrivacyComponent)
|
||||
},
|
||||
{
|
||||
path: 'bans',
|
||||
loadComponent: () => import('./bans/bans.component').then(m => m.BansComponent)
|
||||
loadComponent: () => import('./pages/reference/bans/bans.component').then(m => m.BansComponent)
|
||||
},
|
||||
{
|
||||
path: 'bans/:type/:id',
|
||||
loadComponent: () => import('./bans/details/details.component').then(m => m.DetailsComponent)
|
||||
loadComponent: () => import('./pages/reference/bans/details/details.component').then(m => m.DetailsComponent)
|
||||
},
|
||||
{
|
||||
path: 'economy',
|
||||
loadComponent: () => import('./economy/economy.component').then(m => m.EconomyComponent)
|
||||
loadComponent: () => import('./pages/features/economy/economy.component').then(m => m.EconomyComponent)
|
||||
},
|
||||
{
|
||||
path: 'claiming',
|
||||
loadComponent: () => import('./claiming/claiming.component').then(m => m.ClaimingComponent)
|
||||
loadComponent: () => import('./pages/features/claiming/claiming.component').then(m => m.ClaimingComponent)
|
||||
},
|
||||
{
|
||||
path: 'mypet',
|
||||
loadComponent: () => import('./mypet/mypet.component').then(m => m.MypetComponent)
|
||||
loadComponent: () => import('./pages/features/mypet/mypet.component').then(m => m.MypetComponent)
|
||||
},
|
||||
{
|
||||
path: 'warps',
|
||||
loadComponent: () => import('./warps/warps.component').then(m => m.WarpsComponent)
|
||||
loadComponent: () => import('./pages/features/warps/warps.component').then(m => m.WarpsComponent)
|
||||
},
|
||||
{
|
||||
path: 'skyblock',
|
||||
loadComponent: () => import('./skyblock/skyblock.component').then(m => m.SkyblockComponent)
|
||||
loadComponent: () => import('./pages/features/skyblock/skyblock.component').then(m => m.SkyblockComponent)
|
||||
},
|
||||
{
|
||||
path: 'customfeatures',
|
||||
loadComponent: () => import('./customfeatures/customfeatures.component').then(m => m.CustomfeaturesComponent)
|
||||
loadComponent: () => import('./pages/features/customfeatures/customfeatures.component').then(m => m.CustomfeaturesComponent)
|
||||
},
|
||||
{
|
||||
path: 'guide',
|
||||
loadComponent: () => import('./guide/guide.component').then(m => m.GuideComponent)
|
||||
loadComponent: () => import('./pages/reference/guide/guide.component').then(m => m.GuideComponent)
|
||||
},
|
||||
{
|
||||
path: 'ranks',
|
||||
loadComponent: () => import('./ranks/ranks.component').then(m => m.RanksComponent)
|
||||
loadComponent: () => import('./pages/reference/ranks/ranks.component').then(m => m.RanksComponent)
|
||||
},
|
||||
{
|
||||
path: 'commandlist',
|
||||
loadComponent: () => import('./commandlist/commandlist.component').then(m => m.CommandlistComponent)
|
||||
loadComponent: () => import('./pages/reference/commandlist/commandlist.component').then(m => m.CommandlistComponent)
|
||||
},
|
||||
{
|
||||
path: 'mapart',
|
||||
loadComponent: () => import('./mapart/mapart.component').then(m => m.MapartComponent)
|
||||
loadComponent: () => import('./pages/reference/mapart/mapart.component').then(m => m.MapartComponent)
|
||||
},
|
||||
{
|
||||
path: 'lag',
|
||||
loadComponent: () => import('./lag/lag.component').then(m => m.LagComponent)
|
||||
loadComponent: () => import('./pages/reference/lag/lag.component').then(m => m.LagComponent)
|
||||
},
|
||||
{
|
||||
path: 'staffpowers',
|
||||
loadComponent: () => import('./staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
|
||||
},
|
||||
{
|
||||
path: 'forms/:form',
|
||||
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
|
||||
loadComponent: () => import('./pages/reference/staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
|
||||
},
|
||||
{
|
||||
path: 'forms',
|
||||
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
|
||||
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
|
||||
},
|
||||
{
|
||||
path: 'appeal',
|
||||
redirectTo: 'forms/appeal',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'forms/appeal',
|
||||
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
||||
canActivate: [AuthGuard],
|
||||
data: {
|
||||
requiredAuthorizations: ['SCOPE_user']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'forms/sent',
|
||||
loadComponent: () => import('./pages/forms/sent/sent.component').then(m => m.SentComponent),
|
||||
canActivate: [AuthGuard],
|
||||
data: {
|
||||
requiredAuthorizations: ['SCOPE_user']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'apply',
|
||||
redirectTo: 'forms/staff-application',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'forms/staff-application',
|
||||
loadComponent: () => import('./pages/forms/staff-application/staff-application.component').then(m => m.StaffApplicationComponent),
|
||||
canActivate: [AuthGuard],
|
||||
data: {
|
||||
requiredAuthorizations: ['SCOPE_user']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'community',
|
||||
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)
|
||||
},
|
||||
{
|
||||
path: 'nicknames',
|
||||
loadComponent: () => import('./pages/reference/nicknames/nicknames.component').then(m => m.NicknamesComponent)
|
||||
},
|
||||
{
|
||||
path: 'nickgenerator',
|
||||
loadComponent: () => import('./pages/reference/nickgenerator/nickgenerator.component').then(m => m.NickgeneratorComponent)
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
|
||||
import {map, Observable} from 'rxjs';
|
||||
import {AuthService} from '@services/auth.service';
|
||||
import {MatDialog} from '@angular/material/dialog';
|
||||
import {LoginDialogComponent} from '@shared-components/login/login.component';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthGuard implements CanActivate {
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private router: Router,
|
||||
private dialog: MatDialog
|
||||
) {
|
||||
}
|
||||
|
||||
canActivate(
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
||||
if (!this.authService.isAuthenticated$()) {
|
||||
this.router.createUrlTree(['/']);
|
||||
const dialogRef = this.dialog.open(LoginDialogComponent, {
|
||||
width: '400px',
|
||||
})
|
||||
return dialogRef.afterClosed().pipe(
|
||||
map(result => {
|
||||
if (result) {
|
||||
return this.router.createUrlTree([state.url]);
|
||||
}
|
||||
return this.router.createUrlTree(['/']);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const requiredAuthorizations = route.data['requiredAuthorizations'] as string[];
|
||||
|
||||
if (!requiredAuthorizations || requiredAuthorizations.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userAuthorizations = this.authService.getUserAuthorizations();
|
||||
const hasAccess = requiredAuthorizations.some(auth => userAuthorizations.includes(auth));
|
||||
|
||||
if (!hasAccess) {
|
||||
return this.router.createUrlTree(['/']);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,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) {
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -1,15 +1,14 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {ScrollService} from '../scroll/scroll.service';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {HeaderComponent} from '../header/header.component';
|
||||
import {ScrollService} from '@services/scroll.service';
|
||||
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-birthdays',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
HeaderComponent
|
||||
],
|
||||
],
|
||||
templateUrl: './birthdays.component.html',
|
||||
styleUrl: './birthdays.component.scss'
|
||||
})
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {HeaderComponent} from "../header/header.component";
|
||||
import {HeaderComponent} from "@header/header.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-community',
|
||||
+4
-5
@@ -1,16 +1,15 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {ScrollService} from '../scroll/scroll.service';
|
||||
import {CommonModule, NgOptimizedImage} from '@angular/common';
|
||||
import {HeaderComponent} from '../header/header.component';
|
||||
import {ScrollService} from '@services/scroll.service';
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-socials',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
HeaderComponent,
|
||||
NgOptimizedImage
|
||||
],
|
||||
],
|
||||
templateUrl: './socials.component.html',
|
||||
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>
|
||||
+3
-5
@@ -1,11 +1,10 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {ScrollService} from '../scroll/scroll.service';
|
||||
import {BASE_PATH, Player, TeamService} from '../../api';
|
||||
import {ScrollService} from '@services/scroll.service';
|
||||
import {Player, TeamService} from '@api';
|
||||
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 {map, Observable, shareReplay} from 'rxjs';
|
||||
import {environment} from '../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-team',
|
||||
@@ -17,7 +16,6 @@ import {environment} from '../../environments/environment';
|
||||
],
|
||||
providers: [
|
||||
CookieService,
|
||||
{provide: BASE_PATH, useValue: environment.apiUrl}
|
||||
],
|
||||
|
||||
templateUrl: './team.component.html',
|
||||
+2
-2
@@ -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
|
||||
yourself or do <b>/claim</b> to receive them for free. The shovel is used for modifying claims and the
|
||||
stick is used for viewing claim information.</p>
|
||||
<img ngSrc="/public/img/items/shovel.png" alt="Minecraft golden shovel" style="width: 25%;" height="114"
|
||||
width="114">
|
||||
<img ngSrc="/public/img/items/shovel.png" alt="Minecraft golden shovel"
|
||||
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
|
||||
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
|
||||
+7
@@ -7,3 +7,10 @@ main li {
|
||||
margin-left: 30px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 690px) {
|
||||
.shovelClaiming {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user