Compare commits
49
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 |
@@ -40,6 +40,10 @@ dependencies {
|
|||||||
implementation("org.springframework.boot:spring-boot-starter-mail:3.1.5")
|
implementation("org.springframework.boot:spring-boot-starter-mail:3.1.5")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
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
|
//AOP
|
||||||
implementation("org.aspectj:aspectjrt:1.9.19")
|
implementation("org.aspectj:aspectjrt:1.9.19")
|
||||||
implementation("org.aspectj:aspectjweaver:1.9.19")
|
implementation("org.aspectj:aspectjweaver:1.9.19")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
|||||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||||
import com.nimbusds.jose.proc.SecurityContext;
|
import com.nimbusds.jose.proc.SecurityContext;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
@@ -17,16 +18,22 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
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.JwtDecoder;
|
||||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.interfaces.RSAPrivateKey;
|
import java.security.interfaces.RSAPrivateKey;
|
||||||
import java.security.interfaces.RSAPublicKey;
|
import java.security.interfaces.RSAPublicKey;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -47,13 +54,14 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.requestMatchers("/api/files/save/**").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()
|
.requestMatchers("/api/login/userLogin/**").permitAll()
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
.oauth2ResourceServer(
|
.oauth2ResourceServer(
|
||||||
oauth2 -> oauth2
|
oauth2 -> oauth2
|
||||||
.jwt(Customizer.withDefaults())
|
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
|
||||||
.authenticationEntryPoint(securityAuthFailureHandler)
|
.authenticationEntryPoint(securityAuthFailureHandler)
|
||||||
.accessDeniedHandler(securityAuthFailureHandler)
|
.accessDeniedHandler(securityAuthFailureHandler)
|
||||||
)
|
)
|
||||||
@@ -84,4 +92,46 @@ public class SecurityConfig {
|
|||||||
KeyPair keyPair = keyPairService.getJwtSigningKeyPair();
|
KeyPair keyPair = keyPairService.getJwtSigningKeyPair();
|
||||||
return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public class CorsConfig implements WebMvcConfigurer {
|
|||||||
log.info("Registering CORS mappings for {}", String.join(", ", allowedOrigins));
|
log.info("Registering CORS mappings for {}", String.join(", ", allowedOrigins));
|
||||||
registry.addMapping("/**")
|
registry.addMapping("/**")
|
||||||
.allowedOrigins(allowedOrigins)
|
.allowedOrigins(allowedOrigins)
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||||
.allowedHeaders("*")
|
.allowedHeaders("*")
|
||||||
.allowCredentials(true);
|
.allowCredentials(true);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -1,24 +1,33 @@
|
|||||||
package com.alttd.altitudeweb.controllers.data_from_auth;
|
package com.alttd.altitudeweb.controllers.data_from_auth;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
public class AuthenticatedUuid {
|
public class AuthenticatedUuid {
|
||||||
|
@Value("${UNSECURED:#{false}}")
|
||||||
|
private boolean unsecured;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts and validates the authenticated user's UUID from the JWT token.
|
* Extracts and validates the authenticated user's UUID from the JWT token.
|
||||||
*
|
*
|
||||||
* @return The UUID of the authenticated user
|
* @return The UUID of the authenticated user
|
||||||
* @throws ResponseStatusException with 401 status if authentication is invalid
|
* @throws ResponseStatusException with 401 status if authentication is invalid
|
||||||
*/
|
*/
|
||||||
public static UUID getAuthenticatedUserUuid() {
|
public UUID getAuthenticatedUserUuid() {
|
||||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
|
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");
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-6
@@ -10,8 +10,8 @@ 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.EmailVerification;
|
||||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
||||||
import com.alttd.altitudeweb.mappers.AppealDataMapper;
|
import com.alttd.altitudeweb.mappers.AppealDataMapper;
|
||||||
import com.alttd.altitudeweb.model.AppealResponseDto;
|
|
||||||
import com.alttd.altitudeweb.model.DiscordAppealDto;
|
import com.alttd.altitudeweb.model.DiscordAppealDto;
|
||||||
|
import com.alttd.altitudeweb.model.FormResponseDto;
|
||||||
import com.alttd.altitudeweb.model.MinecraftAppealDto;
|
import com.alttd.altitudeweb.model.MinecraftAppealDto;
|
||||||
import com.alttd.altitudeweb.model.UpdateMailDto;
|
import com.alttd.altitudeweb.model.UpdateMailDto;
|
||||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||||
@@ -25,7 +25,6 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@@ -37,16 +36,18 @@ public class AppealController implements AppealsApi {
|
|||||||
|
|
||||||
private final AppealDataMapper mapper;
|
private final AppealDataMapper mapper;
|
||||||
private final AppealMail appealMail;
|
private final AppealMail appealMail;
|
||||||
|
private final com.alttd.altitudeweb.services.discord.AppealDiscord appealDiscord;
|
||||||
|
|
||||||
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
|
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<AppealResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
|
public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
|
||||||
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
|
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
|
||||||
}
|
}
|
||||||
|
|
||||||
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
|
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<AppealResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
|
public ResponseEntity<FormResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
|
||||||
|
boolean success = true;
|
||||||
CompletableFuture<Appeal> appealCompletableFuture = new CompletableFuture<>();
|
CompletableFuture<Appeal> appealCompletableFuture = new CompletableFuture<>();
|
||||||
|
|
||||||
Connection.getConnection(Databases.DEFAULT)
|
Connection.getConnection(Databases.DEFAULT)
|
||||||
@@ -66,6 +67,15 @@ public class AppealController implements AppealsApi {
|
|||||||
if (history == null) {
|
if (history == null) {
|
||||||
throw new ResponseStatusException(HttpStatusCode.valueOf(404), "History not found");
|
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<>();
|
CompletableFuture<Optional<EmailVerification>> emailVerificationCompletableFuture = new CompletableFuture<>();
|
||||||
Connection.getConnection(Databases.DEFAULT)
|
Connection.getConnection(Databases.DEFAULT)
|
||||||
@@ -85,7 +95,16 @@ public class AppealController implements AppealsApi {
|
|||||||
if (!emailVerification.verified()) {
|
if (!emailVerification.verified()) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
AppealResponseDto appealResponseDto = new AppealResponseDto(
|
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(),
|
appeal.id().toString(),
|
||||||
"Your appeal has been submitted. You will be notified when it has been reviewed.",
|
"Your appeal has been submitted. You will be notified when it has been reviewed.",
|
||||||
true);
|
true);
|
||||||
@@ -93,7 +112,8 @@ public class AppealController implements AppealsApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<AppealResponseDto> updateMail(UpdateMailDto updateMailDto) {
|
public ResponseEntity<FormResponseDto> updateMail(UpdateMailDto updateMailDto) {
|
||||||
|
//TODO move to its own endpoint
|
||||||
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Updating mail is not yet supported");
|
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Updating mail is not yet supported");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,11 +28,12 @@ import java.util.concurrent.TimeUnit;
|
|||||||
public class MailController implements MailApi {
|
public class MailController implements MailApi {
|
||||||
|
|
||||||
private final MailVerificationService mailVerificationService;
|
private final MailVerificationService mailVerificationService;
|
||||||
|
private final AuthenticatedUuid authenticatedUuid;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit")
|
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit")
|
||||||
public ResponseEntity<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) {
|
public ResponseEntity<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) {
|
||||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream()
|
boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream()
|
||||||
.filter(EmailVerification::verified)
|
.filter(EmailVerification::verified)
|
||||||
.map(EmailVerification::email)
|
.map(EmailVerification::email)
|
||||||
@@ -52,7 +53,7 @@ public class MailController implements MailApi {
|
|||||||
@Override
|
@Override
|
||||||
@RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify")
|
@RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify")
|
||||||
public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
|
public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
|
||||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode());
|
Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode());
|
||||||
if (optionalEmailVerification.isEmpty()) {
|
if (optionalEmailVerification.isEmpty()) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code");
|
||||||
@@ -68,7 +69,7 @@ public class MailController implements MailApi {
|
|||||||
@Override
|
@Override
|
||||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend")
|
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend")
|
||||||
public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) {
|
public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) {
|
||||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
EmailVerification updated = mailVerificationService.resend(uuid, submitEmailDto.getEmail());
|
EmailVerification updated = mailVerificationService.resend(uuid, submitEmailDto.getEmail());
|
||||||
if (updated == null) {
|
if (updated == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
|
||||||
@@ -83,7 +84,7 @@ public class MailController implements MailApi {
|
|||||||
@Override
|
@Override
|
||||||
@RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete")
|
@RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete")
|
||||||
public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) {
|
public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) {
|
||||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail());
|
boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail());
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
|
||||||
@@ -93,7 +94,7 @@ public class MailController implements MailApi {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<List<EmailEntryDto>> getUserEmails() {
|
public ResponseEntity<List<EmailEntryDto>> getUserEmails() {
|
||||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
List<EmailVerification> emails = mailVerificationService.listAll(uuid);
|
List<EmailVerification> emails = mailVerificationService.listAll(uuid);
|
||||||
List<EmailEntryDto> result = emails.stream()
|
List<EmailEntryDto> result = emails.stream()
|
||||||
.map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified()))
|
.map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified()))
|
||||||
|
|||||||
+111
-1
@@ -1,6 +1,7 @@
|
|||||||
package com.alttd.altitudeweb.controllers.history;
|
package com.alttd.altitudeweb.controllers.history;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.api.HistoryApi;
|
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.services.limits.RateLimit;
|
||||||
import com.alttd.altitudeweb.model.HistoryCountDto;
|
import com.alttd.altitudeweb.model.HistoryCountDto;
|
||||||
import com.alttd.altitudeweb.model.PunishmentHistoryListDto;
|
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.Databases;
|
||||||
import com.alttd.altitudeweb.database.litebans.*;
|
import com.alttd.altitudeweb.database.litebans.*;
|
||||||
import com.alttd.altitudeweb.model.PunishmentHistoryDto;
|
import com.alttd.altitudeweb.model.PunishmentHistoryDto;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -20,8 +23,11 @@ import java.util.concurrent.TimeUnit;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RateLimit(limit = 30, timeValue = 10, timeUnit = TimeUnit.SECONDS)
|
@RateLimit(limit = 30, timeValue = 10, timeUnit = TimeUnit.SECONDS)
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class HistoryApiController implements HistoryApi {
|
public class HistoryApiController implements HistoryApi {
|
||||||
|
|
||||||
|
private final AuthenticatedUuid authenticatedUuid;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<PunishmentHistoryListDto> getHistoryForAll(String userType, String type, Integer page) {
|
public ResponseEntity<PunishmentHistoryListDto> getHistoryForAll(String userType, String type, Integer page) {
|
||||||
return getHistoryForUsers(userType, type, "", page);
|
return getHistoryForUsers(userType, type, "", page);
|
||||||
@@ -229,4 +235,108 @@ public class HistoryApiController implements HistoryApi {
|
|||||||
.type(type)
|
.type(type)
|
||||||
.id(historyRecord.getId());
|
.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-11
@@ -2,26 +2,21 @@ package com.alttd.altitudeweb.controllers.login;
|
|||||||
|
|
||||||
import com.alttd.altitudeweb.api.LoginApi;
|
import com.alttd.altitudeweb.api.LoginApi;
|
||||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||||
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
|
||||||
import com.alttd.altitudeweb.database.litebans.RecentNamesMapper;
|
|
||||||
import com.alttd.altitudeweb.database.litebans.UUIDHistoryMapper;
|
|
||||||
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
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.PrivilegedUser;
|
||||||
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
||||||
|
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
||||||
import com.alttd.altitudeweb.model.UsernameDto;
|
import com.alttd.altitudeweb.model.UsernameDto;
|
||||||
|
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||||
import com.alttd.altitudeweb.setup.Connection;
|
import com.alttd.altitudeweb.setup.Connection;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
|
||||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||||
@@ -37,10 +32,12 @@ import java.util.concurrent.TimeUnit;
|
|||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@EnableScheduling
|
||||||
@RestController
|
@RestController
|
||||||
public class LoginController implements LoginApi {
|
public class LoginController implements LoginApi {
|
||||||
|
|
||||||
private final JwtEncoder jwtEncoder;
|
private final JwtEncoder jwtEncoder;
|
||||||
|
private final AuthenticatedUuid authenticatedUuid;
|
||||||
|
|
||||||
@Value("${login.secret:#{null}}")
|
@Value("${login.secret:#{null}}")
|
||||||
private String loginSecret;
|
private String loginSecret;
|
||||||
@@ -54,7 +51,7 @@ public class LoginController implements LoginApi {
|
|||||||
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@Scheduled(fixedRate = 300000) // 5 minutes in milliseconds
|
@Scheduled(fixedRate = 300000) // 5 minutes in milliseconds
|
||||||
private void clearExpiredCacheEntries() {
|
protected void clearExpiredCacheEntries() {
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
int initialCacheSize = cache.size();
|
int initialCacheSize = cache.size();
|
||||||
cache.entrySet().removeIf(entry -> entry.getValue().expiry().isBefore(now));
|
cache.entrySet().removeIf(entry -> entry.getValue().expiry().isBefore(now));
|
||||||
@@ -71,6 +68,8 @@ public class LoginController implements LoginApi {
|
|||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info("{} is requesting a login code", uuid);
|
||||||
|
|
||||||
if (authorization == null || !authorization.startsWith("SECRET ")) {
|
if (authorization == null || !authorization.startsWith("SECRET ")) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||||
}
|
}
|
||||||
@@ -86,10 +85,12 @@ public class LoginController implements LoginApi {
|
|||||||
.findFirst();
|
.findFirst();
|
||||||
|
|
||||||
if (key.isPresent()) {
|
if (key.isPresent()) {
|
||||||
|
log.info("{} got cached key: {}", uuid, key.get());
|
||||||
return ResponseEntity.ok(key.get());
|
return ResponseEntity.ok(key.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
String loginCode = generateLoginCode(uuidFromString);
|
String loginCode = generateLoginCode(uuidFromString);
|
||||||
|
log.info("{} received login code: {}", uuid, loginCode);
|
||||||
return ResponseEntity.ok(loginCode);
|
return ResponseEntity.ok(loginCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ public class LoginController implements LoginApi {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Get authenticated UUID using the utility method
|
// Get authenticated UUID using the utility method
|
||||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
||||||
log.debug("Loaded username for logged in user {}", uuid);
|
log.debug("Loaded username for logged in user {}", uuid);
|
||||||
|
|
||||||
// Create response with username
|
// Create response with username
|
||||||
|
|||||||
@@ -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,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,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080}
|
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080,http://localhost:80}
|
||||||
my-server.address=${SERVER_ADDRESS:http://localhost:8080}
|
my-server.address=${SERVER_ADDRESS:http://localhost:8080}
|
||||||
logging.level.com.alttd.altitudeweb=DEBUG
|
logging.level.com.alttd.altitudeweb=DEBUG
|
||||||
logging.level.org.springframework.security=DEBUG
|
logging.level.org.springframework.security=DEBUG
|
||||||
|
|||||||
@@ -4,15 +4,103 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Appeal Notification</title>
|
<title>Appeal Notification</title>
|
||||||
<style>
|
<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 {
|
ul {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
padding-left: 20px;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2 th:text="'Appeal by ' + ${appeal.username}">Appeal by Username</h2>
|
<main>
|
||||||
<p>Punishment information</p>
|
<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>
|
<ul>
|
||||||
<li><strong>Username:</strong> <span th:text="${appeal.username}">username</span></li>
|
<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>UUID:</strong> <span th:text="${appeal.uuid}">uuid</span></li>
|
||||||
@@ -21,7 +109,16 @@
|
|||||||
<li><strong>Reason:</strong> <span th:text="${history.reason}">reason</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>
|
<li><strong>Active:</strong> <span th:text="${active}">unknown</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Appeal:</h3>
|
</div>
|
||||||
<p th:text="${appeal.reason}">Reason text</p>
|
</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>
|
</body>
|
||||||
</html>
|
</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>
|
||||||
@@ -7,7 +7,8 @@ public enum Databases {
|
|||||||
DEFAULT("web_db"),
|
DEFAULT("web_db"),
|
||||||
LUCK_PERMS("luckperms"),
|
LUCK_PERMS("luckperms"),
|
||||||
LITE_BANS("litebans"),
|
LITE_BANS("litebans"),
|
||||||
DISCORD("discordLink");
|
DISCORD("discordLink"),
|
||||||
|
VOTING_PLUGIN("votingplugin");
|
||||||
|
|
||||||
private final String internalName;
|
private final String internalName;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -12,7 +12,8 @@ public interface RecentNamesMapper {
|
|||||||
SELECT DISTINCT name AS username
|
SELECT DISTINCT name AS username
|
||||||
FROM litebans_history
|
FROM litebans_history
|
||||||
WHERE uuid = #{uuid}
|
WHERE uuid = #{uuid}
|
||||||
ORDER BY date DESC;
|
ORDER BY date DESC
|
||||||
|
LIMIT 1;
|
||||||
""")
|
""")
|
||||||
String getUsername(@Param("uuid") String uuid);
|
String getUsername(@Param("uuid") String uuid);
|
||||||
|
|
||||||
|
|||||||
+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
|
||||||
|
) { }
|
||||||
@@ -3,6 +3,6 @@ package com.alttd.altitudeweb.database.web_db;
|
|||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
public interface SettingsMapper {
|
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);
|
DatabaseSettings getSettings(String database);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package com.alttd.altitudeweb.database.web_db.forms;
|
|||||||
|
|
||||||
import org.apache.ibatis.annotations.Insert;
|
import org.apache.ibatis.annotations.Insert;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface AppealMapper {
|
public interface AppealMapper {
|
||||||
|
|
||||||
@@ -26,4 +28,10 @@ public interface AppealMapper {
|
|||||||
WHERE uuid = #{uuid}
|
WHERE uuid = #{uuid}
|
||||||
""")
|
""")
|
||||||
List<Appeal> getAppealsByUuid(String 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);
|
||||||
|
}
|
||||||
@@ -12,10 +12,12 @@ import org.apache.ibatis.session.SqlSession;
|
|||||||
import org.apache.ibatis.session.SqlSessionFactory;
|
import org.apache.ibatis.session.SqlSessionFactory;
|
||||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
||||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CompletionException;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -36,6 +38,7 @@ public class Connection {
|
|||||||
InitializeLiteBans.init();
|
InitializeLiteBans.init();
|
||||||
InitializeLuckPerms.init();
|
InitializeLuckPerms.init();
|
||||||
InitializeDiscord.init();
|
InitializeDiscord.init();
|
||||||
|
InitializeVotingPlugin.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
@@ -57,25 +60,46 @@ public class Connection {
|
|||||||
if (database == Databases.DEFAULT) {
|
if (database == Databases.DEFAULT) {
|
||||||
return loadDefaultDatabase(addMappers);
|
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());
|
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 -> {
|
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());
|
DatabaseSettings loadedSettings = session.getMapper(SettingsMapper.class).getSettings(database.getInternalName());
|
||||||
|
|
||||||
if (loadedSettings == null) {
|
if (loadedSettings == null) {
|
||||||
log.error("Failed to load settings for database {}", database.getInternalName());
|
log.error("Failed to load settings for database {}. No settings found in db_connection_settings table.",
|
||||||
}
|
database.getInternalName());
|
||||||
log.debug("Loaded settings {}", loadedSettings);
|
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);
|
settingsFuture.complete(loadedSettings);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error occurred while loading database settings for {}", database.getInternalName(), e);
|
||||||
|
settingsFuture.completeExceptionally(e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return null;
|
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 -> {
|
return settingsFuture.thenApply(loadedSettings -> {
|
||||||
log.debug("Storing connection for database {}", database.getInternalName());
|
log.debug("Storing connection for database {}", database.getInternalName());
|
||||||
Connection connection = new Connection(loadedSettings, addMappers);
|
Connection connection = new Connection(loadedSettings, addMappers);
|
||||||
connections.put(database, connection);
|
connections.put(database, connection);
|
||||||
return 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,18 +143,47 @@ public class Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private SqlSessionFactory createSqlSessionFactory(DatabaseSettings settings, AddMappers addMappers) {
|
private SqlSessionFactory createSqlSessionFactory(DatabaseSettings settings, AddMappers addMappers) {
|
||||||
PooledDataSource dataSource = new PooledDataSource();
|
try {
|
||||||
dataSource.setDriver("com.mysql.cj.jdbc.Driver");
|
Configuration configuration = getConfiguration(settings);
|
||||||
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);
|
|
||||||
configuration.getTypeHandlerRegistry().register(UUID.class, UUIDTypeHandler.class);
|
configuration.getTypeHandlerRegistry().register(UUID.class, UUIDTypeHandler.class);
|
||||||
addMappers.apply(configuration);
|
addMappers.apply(configuration);
|
||||||
|
|
||||||
return new SqlSessionFactoryBuilder().build(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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class InitializeLiteBans {
|
|||||||
configuration.addMapper(UUIDHistoryMapper.class);
|
configuration.addMapper(UUIDHistoryMapper.class);
|
||||||
configuration.addMapper(HistoryCountMapper.class);
|
configuration.addMapper(HistoryCountMapper.class);
|
||||||
configuration.addMapper(IdHistoryMapper.class);
|
configuration.addMapper(IdHistoryMapper.class);
|
||||||
|
configuration.addMapper(EditHistoryMapper.class);
|
||||||
}).join()
|
}).join()
|
||||||
.runQuery(sqlSession -> {
|
.runQuery(sqlSession -> {
|
||||||
createAllPunishmentsView(sqlSession);
|
createAllPunishmentsView(sqlSession);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.alttd.altitudeweb.setup;
|
|||||||
|
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
import com.alttd.altitudeweb.database.Databases;
|
||||||
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
|
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
|
||||||
|
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -11,6 +12,7 @@ public class InitializeLuckPerms {
|
|||||||
log.info("Initializing LuckPerms");
|
log.info("Initializing LuckPerms");
|
||||||
Connection.getConnection(Databases.LUCK_PERMS, (configuration) -> {
|
Connection.getConnection(Databases.LUCK_PERMS, (configuration) -> {
|
||||||
configuration.addMapper(TeamMemberMapper.class);
|
configuration.addMapper(TeamMemberMapper.class);
|
||||||
|
configuration.addMapper(UUIDUsernameMapper.class);
|
||||||
}).join();
|
}).join();
|
||||||
log.debug("Initialized LuckPerms");
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
|
|||||||
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
||||||
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
|
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
|
||||||
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
|
import 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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.ibatis.session.SqlSession;
|
import org.apache.ibatis.session.SqlSession;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
@@ -22,7 +24,8 @@ public class InitializeWebDb {
|
|||||||
configuration.addMapper(KeyPairMapper.class);
|
configuration.addMapper(KeyPairMapper.class);
|
||||||
configuration.addMapper(PrivilegedUserMapper.class);
|
configuration.addMapper(PrivilegedUserMapper.class);
|
||||||
configuration.addMapper(AppealMapper.class);
|
configuration.addMapper(AppealMapper.class);
|
||||||
configuration.addMapper(com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper.class);
|
configuration.addMapper(StaffApplicationMapper.class);
|
||||||
|
configuration.addMapper(EmailVerificationMapper.class);
|
||||||
}).join()
|
}).join()
|
||||||
.runQuery(sqlSession -> {
|
.runQuery(sqlSession -> {
|
||||||
createSettingsTable(sqlSession);
|
createSettingsTable(sqlSession);
|
||||||
@@ -30,6 +33,7 @@ public class InitializeWebDb {
|
|||||||
createPrivilegedUsersTable(sqlSession);
|
createPrivilegedUsersTable(sqlSession);
|
||||||
createPrivilegesTable(sqlSession);
|
createPrivilegesTable(sqlSession);
|
||||||
createAppealTable(sqlSession);
|
createAppealTable(sqlSession);
|
||||||
|
createStaffApplicationsTable(sqlSession);
|
||||||
createUserEmailsTable(sqlSession);
|
createUserEmailsTable(sqlSession);
|
||||||
});
|
});
|
||||||
log.debug("Initialized WebDb");
|
log.debug("Initialized WebDb");
|
||||||
@@ -125,6 +129,37 @@ public class InitializeWebDb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private static void createAppealTable(@NotNull SqlSession sqlSession) {
|
||||||
String query = """
|
String query = """
|
||||||
CREATE TABLE IF NOT EXISTS appeals (
|
CREATE TABLE IF NOT EXISTS appeals (
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
package com.alttd.webinterface.bot;
|
package com.alttd.webinterface.bot;
|
||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.dv8tion.jda.api.JDA;
|
import net.dv8tion.jda.api.JDA;
|
||||||
import net.dv8tion.jda.api.JDABuilder;
|
import net.dv8tion.jda.api.JDABuilder;
|
||||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
public class DiscordBotInstance {
|
public class DiscordBotInstance {
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
private JDA jda;
|
private JDA jda;
|
||||||
|
private volatile boolean ready = false;
|
||||||
|
|
||||||
public void start(String token) {
|
public synchronized void start(String token) {
|
||||||
|
if (jda != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
jda = JDABuilder.createDefault(token,
|
jda = JDABuilder.createDefault(token,
|
||||||
GatewayIntent.GUILD_MEMBERS,
|
GatewayIntent.GUILD_MEMBERS,
|
||||||
GatewayIntent.GUILD_PRESENCES,
|
GatewayIntent.GUILD_PRESENCES,
|
||||||
GatewayIntent.GUILD_MESSAGES,
|
GatewayIntent.GUILD_MESSAGES,
|
||||||
GatewayIntent.MESSAGE_CONTENT)
|
GatewayIntent.MESSAGE_CONTENT)
|
||||||
|
.addEventListeners(new ReadyListener(() -> {
|
||||||
|
ready = true;
|
||||||
|
}))
|
||||||
.build();
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
},
|
||||||
"builder": "@angular/build:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:8080",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,11 @@ export const routes: Routes = [
|
|||||||
path: 'forms',
|
path: 'forms',
|
||||||
loadComponent: () => import('./pages/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',
|
path: 'forms/appeal',
|
||||||
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
||||||
@@ -126,6 +131,19 @@ export const routes: Routes = [
|
|||||||
requiredAuthorizations: ['SCOPE_user']
|
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',
|
path: 'community',
|
||||||
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)
|
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {Injectable} from '@angular/core';
|
import {Injectable} from '@angular/core';
|
||||||
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
|
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
|
||||||
import {Observable} from 'rxjs';
|
import {map, Observable} from 'rxjs';
|
||||||
import {AuthService} from '@services/auth.service';
|
import {AuthService} from '@services/auth.service';
|
||||||
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
|
import {LoginDialogComponent} from '@shared-components/login/login.component';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -10,7 +12,8 @@ export class AuthGuard implements CanActivate {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private router: Router
|
private router: Router,
|
||||||
|
private dialog: MatDialog
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,8 +21,19 @@ export class AuthGuard implements CanActivate {
|
|||||||
route: ActivatedRouteSnapshot,
|
route: ActivatedRouteSnapshot,
|
||||||
state: RouterStateSnapshot
|
state: RouterStateSnapshot
|
||||||
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
||||||
if (!this.authService.checkAuthStatus()) {
|
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(['/']);
|
return this.router.createUrlTree(['/']);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiredAuthorizations = route.data['requiredAuthorizations'] as string[];
|
const requiredAuthorizations = route.data['requiredAuthorizations'] as string[];
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import {Component} from '@angular/core';
|
import {Component} from '@angular/core';
|
||||||
import {ScrollService} from '@services/scroll.service';
|
import {ScrollService} from '@services/scroll.service';
|
||||||
import {BASE_PATH, Player, TeamService} from '@api';
|
import {Player, TeamService} from '@api';
|
||||||
import {CommonModule, NgOptimizedImage} from '@angular/common';
|
import {CommonModule, NgOptimizedImage} from '@angular/common';
|
||||||
import {HeaderComponent} from '@header/header.component';
|
import {HeaderComponent} from '@header/header.component';
|
||||||
import {CookieService} from 'ngx-cookie-service';
|
import {CookieService} from 'ngx-cookie-service';
|
||||||
import {map, Observable, shareReplay} from 'rxjs';
|
import {map, Observable, shareReplay} from 'rxjs';
|
||||||
import {environment} from '@environment';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-team',
|
selector: 'app-team',
|
||||||
@@ -17,7 +16,6 @@ import {environment} from '@environment';
|
|||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
CookieService,
|
CookieService,
|
||||||
{provide: BASE_PATH, useValue: environment.apiUrl}
|
|
||||||
],
|
],
|
||||||
|
|
||||||
templateUrl: './team.component.html',
|
templateUrl: './team.component.html',
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
<h1>Dynamic Server Maps</h1>
|
<h1>Dynamic Server Maps</h1>
|
||||||
<h2>Which server would you like to see?</h2>
|
<h2>Which server would you like to see?</h2>
|
||||||
<div>
|
<div>
|
||||||
<a href="https://gmap.alttd.com">
|
<a href="https://bmap.alttd.com">
|
||||||
<div style="background-color: #F547B2;" class="button-outer map-button">
|
<div style="background-color: #F547B2;" class="button-outer map-button">
|
||||||
<span class="button-inner">Grove</span>
|
<span class="button-inner">Bayou</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://cmap.alttd.com">
|
<a href="https://cmap.alttd.com">
|
||||||
|
|||||||
@@ -81,8 +81,7 @@
|
|||||||
<mat-label>Email</mat-label>
|
<mat-label>Email</mat-label>
|
||||||
<input matInput
|
<input matInput
|
||||||
formControlName="email"
|
formControlName="email"
|
||||||
placeholder="Email"
|
placeholder="Email">
|
||||||
[defaultValue]="verifiedEmails().length > 0 ? verifiedEmails()[0] : ''">
|
|
||||||
@if (form.controls.email.invalid && form.controls.email.touched) {
|
@if (form.controls.email.invalid && form.controls.email.touched) {
|
||||||
<mat-error>
|
<mat-error>
|
||||||
@if (form.controls.email.errors?.['required']) {
|
@if (form.controls.email.errors?.['required']) {
|
||||||
@@ -97,7 +96,7 @@
|
|||||||
<div class="valid-email">
|
<div class="valid-email">
|
||||||
<ng-container matSuffix>
|
<ng-container matSuffix>
|
||||||
<mat-icon>check</mat-icon>
|
<mat-icon>check</mat-icon>
|
||||||
<span>Your have already validated your email, and can continue to the next page!</span>
|
<span>You have validated your email previously, and can continue to the next page!</span>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,12 @@ export class AppealComponent implements OnInit, OnDestroy, AfterViewInit {
|
|||||||
this.emailIsValid.set(false);
|
this.emailIsValid.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
computed(() => {
|
||||||
|
if (this.verifiedEmails().length > 0) {
|
||||||
|
this.form.get('email')?.setValue(this.verifiedEmails()[0]);
|
||||||
|
this.emailIsValid.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@@ -187,7 +192,7 @@ export class AppealComponent implements OnInit, OnDestroy, AfterViewInit {
|
|||||||
if (!result.verified_mail) {
|
if (!result.verified_mail) {
|
||||||
throw new Error('Mail not verified');
|
throw new Error('Mail not verified');
|
||||||
}
|
}
|
||||||
this.router.navigate(['/form/sent'], {
|
this.router.navigate(['/forms/sent'], {
|
||||||
state: {message: result.message}
|
state: {message: result.message}
|
||||||
}).then();
|
}).then();
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,14 @@
|
|||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="columnParagraph">
|
||||||
|
<a [routerLink]="['/forms/staff-application']">
|
||||||
|
<h2>Staff Application</h2>
|
||||||
|
<p>
|
||||||
|
Interested in becoming a moderator on our server? Apply here.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Component, OnInit} from '@angular/core';
|
import {Component, inject, OnInit} from '@angular/core';
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {HeaderComponent} from '@header/header.component';
|
import {HeaderComponent} from '@header/header.component';
|
||||||
|
|
||||||
@@ -13,16 +13,14 @@ import {HeaderComponent} from '@header/header.component';
|
|||||||
})
|
})
|
||||||
export class SentComponent implements OnInit {
|
export class SentComponent implements OnInit {
|
||||||
protected message: string = "The form is completed and has been sent";
|
protected message: string = "The form is completed and has been sent";
|
||||||
|
private router: Router = inject(Router)
|
||||||
constructor(private router: Router) {
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const navigation = this.router.getCurrentNavigation();
|
const state = this.router.getCurrentNavigation()?.extras.state || history.state;
|
||||||
const state = navigation?.extras.state as { message: string };
|
|
||||||
|
|
||||||
if (state?.message) {
|
if (state && 'message' in state) {
|
||||||
this.message = state.message;
|
this.message = state.message as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
<div>
|
||||||
|
<app-header [current_page]="'staff-application'" height="200px" background_image="/public/img/backgrounds/staff.png"
|
||||||
|
[overlay_gradient]="0.5">
|
||||||
|
<div class="title" header-content>
|
||||||
|
<h1>Staff Application</h1>
|
||||||
|
</div>
|
||||||
|
</app-header>
|
||||||
|
<main>
|
||||||
|
@if (staffApplicationsIsOpen()) {
|
||||||
|
<section class="darkmodeSection staff-application-container">
|
||||||
|
<div class="form-container">
|
||||||
|
<div class="pages">
|
||||||
|
<!-- Welcome Page -->
|
||||||
|
@if (currentPageIndex === 0) {
|
||||||
|
<section class="formPage">
|
||||||
|
<img ngSrc="/public/img/logos/logo.png" alt="Logo" height="319" width="550"/>
|
||||||
|
<h1>Moderator Application</h1>
|
||||||
|
<p>Thank you for your interest in becoming a moderator on our Minecraft server.</p>
|
||||||
|
<p>Please take your time to fill out this application thoroughly.</p>
|
||||||
|
<button mat-raised-button (click)="nextPage()">
|
||||||
|
Get Started
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Confirmation Page -->
|
||||||
|
@if (currentPageIndex === 1) {
|
||||||
|
<section class="formPage">
|
||||||
|
<div class="description">
|
||||||
|
<p>You are logged in as <strong>{{ authService.username() }}</strong>. If this is the correct account
|
||||||
|
please continue</p>
|
||||||
|
<br>
|
||||||
|
<p><strong>Notice: </strong> Submitting a staff application is <strong>not</strong> an instant
|
||||||
|
process.
|
||||||
|
We will review your application carefully and get back to you if we think you're a good fit.</p>
|
||||||
|
<p style="font-style: italic;">Applications that seem to have been made with
|
||||||
|
little to no effort will be automatically rejected.</p>
|
||||||
|
</div>
|
||||||
|
<button mat-raised-button (click)="nextPage()" [disabled]="authService.username() == null">
|
||||||
|
I, {{ authService.username() }}, understand and agree
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form [formGroup]="form">
|
||||||
|
<!-- Basic Information Page -->
|
||||||
|
@if (currentPageIndex === 2) {
|
||||||
|
<section class="formPage">
|
||||||
|
<div class="description">
|
||||||
|
<h2>Basic Information</h2>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<mat-form-field appearance="fill" style="width: 100%;">
|
||||||
|
<mat-label>Email</mat-label>
|
||||||
|
<input matInput
|
||||||
|
formControlName="email"
|
||||||
|
placeholder="Email">
|
||||||
|
@if (form.controls.email.invalid && form.controls.email.touched) {
|
||||||
|
<mat-error>
|
||||||
|
@if (form.controls.email.errors?.['required']) {
|
||||||
|
Email is required
|
||||||
|
} @else if (form.controls.email.errors?.['email']) {
|
||||||
|
Please enter a valid email address
|
||||||
|
}
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
@if (emailIsValid()) {
|
||||||
|
<div class="valid-email">
|
||||||
|
<ng-container matSuffix>
|
||||||
|
<mat-icon>check</mat-icon>
|
||||||
|
<span>You have validated your email previously.</span>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="invalid-email">
|
||||||
|
<ng-container matSuffix>
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
<span>You have not used this email address before. Before going to the next page you will be asked to verify it.</span>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Age -->
|
||||||
|
<mat-form-field appearance="fill" style="width: 100%;">
|
||||||
|
<mat-label>Age</mat-label>
|
||||||
|
<input matInput
|
||||||
|
formControlName="age"
|
||||||
|
placeholder="Age">
|
||||||
|
@if (form.controls.age.invalid && form.controls.age.touched) {
|
||||||
|
<mat-error>
|
||||||
|
@if (form.controls.age.errors?.['required']) {
|
||||||
|
Age is required
|
||||||
|
} @else if (form.controls.age.errors?.['min']) {
|
||||||
|
You must be at least 13 years old
|
||||||
|
} @else if (form.controls.age.errors?.['pattern']) {
|
||||||
|
Please enter a valid number
|
||||||
|
}
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Discord Username -->
|
||||||
|
<mat-form-field appearance="fill" style="width: 100%;">
|
||||||
|
<mat-label>Discord Username</mat-label>
|
||||||
|
<input matInput
|
||||||
|
formControlName="discordUsername"
|
||||||
|
placeholder="Discord Username">
|
||||||
|
@if (form.controls.discordUsername.invalid && form.controls.discordUsername.touched) {
|
||||||
|
<mat-error>
|
||||||
|
Discord username is required
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- PC Requirements -->
|
||||||
|
<div class="checkbox-field">
|
||||||
|
<mat-checkbox formControlName="meetsRequirements">
|
||||||
|
I confirm that I meet the PC requirements (able to record video at 30fps 720p or higher, and
|
||||||
|
able
|
||||||
|
to talk in voice chat)
|
||||||
|
</mat-checkbox>
|
||||||
|
@if (form.controls.meetsRequirements.invalid && form.controls.meetsRequirements.touched) {
|
||||||
|
<mat-error class="checkbox-error">
|
||||||
|
You must meet the PC requirements to apply
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pronouns (Optional) -->
|
||||||
|
<mat-form-field appearance="fill" style="width: 100%;">
|
||||||
|
<mat-label>Pronouns (Optional)</mat-label>
|
||||||
|
<input matInput
|
||||||
|
formControlName="pronouns"
|
||||||
|
placeholder="Pronouns">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-raised-button (click)="validateMailOrNextPage()"
|
||||||
|
[disabled]="form.controls.email.invalid || form.controls.age.invalid || form.controls.discordUsername.invalid || !form.controls.meetsRequirements.value">
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Experience Page -->
|
||||||
|
@if (currentPageIndex === 3) {
|
||||||
|
<section class="formPage">
|
||||||
|
<div class="description">
|
||||||
|
<h2>Experience & Availability</h2>
|
||||||
|
|
||||||
|
<!-- Join Date -->
|
||||||
|
<mat-form-field appearance="fill" style="width: 100%;">
|
||||||
|
<mat-label>When did you join our server? (Estimate)</mat-label>
|
||||||
|
<input matInput [matDatepicker]="picker" formControlName="joinDate">
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #picker></mat-datepicker>
|
||||||
|
@if (form.controls.joinDate.invalid && form.controls.joinDate.touched) {
|
||||||
|
<mat-error>
|
||||||
|
Join date is required
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Weekly Playtime -->
|
||||||
|
<mat-form-field appearance="fill" style="width: 100%;">
|
||||||
|
<mat-label>Average expected playtime in a week (hours)</mat-label>
|
||||||
|
<input matInput
|
||||||
|
formControlName="weeklyPlaytime"
|
||||||
|
placeholder="Hours per week">
|
||||||
|
@if (form.controls.weeklyPlaytime.invalid && form.controls.weeklyPlaytime.touched) {
|
||||||
|
<mat-error>
|
||||||
|
@if (form.controls.weeklyPlaytime.errors?.['required']) {
|
||||||
|
Weekly playtime is required
|
||||||
|
} @else if (form.controls.weeklyPlaytime.errors?.['min']) {
|
||||||
|
Weekly playtime must be at least 1 hour
|
||||||
|
} @else if (form.controls.weeklyPlaytime.errors?.['pattern']) {
|
||||||
|
Please enter a whole number of hours (e.g., 20)
|
||||||
|
}
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Available Days -->
|
||||||
|
<div class="field-container">
|
||||||
|
<label class="field-label">Available days for moderating:</label>
|
||||||
|
<div class="days-container">
|
||||||
|
@for (day of availableDays; track day) {
|
||||||
|
<div class="day-chip" [class.selected]="form.controls.availableDays.value.includes(day)"
|
||||||
|
(click)="toggleDay(day)">
|
||||||
|
{{ day }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (form.controls.availableDays.invalid && form.controls.availableDays.touched) {
|
||||||
|
<mat-error>
|
||||||
|
Please select at least one day
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available Times -->
|
||||||
|
<mat-form-field appearance="fill" style="width: 100%;">
|
||||||
|
<mat-label>Available times (Your timezone: {{ userTimezone }})</mat-label>
|
||||||
|
<textarea matInput
|
||||||
|
formControlName="availableTimes"
|
||||||
|
placeholder="e.g., 6PM-10PM weekdays, 2PM-8PM weekends"
|
||||||
|
rows="2"></textarea>
|
||||||
|
@if (form.controls.availableTimes.invalid && form.controls.availableTimes.touched) {
|
||||||
|
<mat-error>
|
||||||
|
Available times are required
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-raised-button (click)="nextPage()"
|
||||||
|
[disabled]="form.controls.joinDate.invalid || form.controls.weeklyPlaytime.invalid || form.controls.availableDays.invalid || form.controls.availableTimes.invalid">
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Qualifications Page -->
|
||||||
|
@if (currentPageIndex === 4) {
|
||||||
|
<section class="formPage">
|
||||||
|
<div class="description">
|
||||||
|
<h2>Qualifications & Expectations</h2>
|
||||||
|
|
||||||
|
<!-- Previous Experience -->
|
||||||
|
<mat-form-field appearance="fill" style="width: 100%;">
|
||||||
|
<mat-label>Previous experience (here or in other relevant places)</mat-label>
|
||||||
|
<textarea matInput
|
||||||
|
formControlName="previousExperience"
|
||||||
|
placeholder="Describe your previous experience"
|
||||||
|
rows="4"></textarea>
|
||||||
|
@if (form.controls.previousExperience.invalid && form.controls.previousExperience.touched) {
|
||||||
|
<mat-error>
|
||||||
|
@if (form.controls.previousExperience.errors?.['required']) {
|
||||||
|
Previous experience is required
|
||||||
|
} @else if (form.controls.previousExperience.errors?.['minlength']) {
|
||||||
|
Please provide more details (at least 10 characters)
|
||||||
|
}
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Plugin Experience -->
|
||||||
|
<mat-form-field appearance="fill" style="width: 100%;">
|
||||||
|
<mat-label>Experience with plugins that players use on our server</mat-label>
|
||||||
|
<textarea matInput
|
||||||
|
formControlName="pluginExperience"
|
||||||
|
placeholder="Describe your experience with our server plugins"
|
||||||
|
rows="4"></textarea>
|
||||||
|
@if (form.controls.pluginExperience.invalid && form.controls.pluginExperience.touched) {
|
||||||
|
<mat-error>
|
||||||
|
@if (form.controls.pluginExperience.errors?.['required']) {
|
||||||
|
Plugin experience is required
|
||||||
|
} @else if (form.controls.pluginExperience.errors?.['minlength']) {
|
||||||
|
Please provide more details (at least 10 characters)
|
||||||
|
}
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Moderator Expectations -->
|
||||||
|
<mat-form-field appearance="fill" style="width: 100%;">
|
||||||
|
<mat-label>What do you believe the expectations of a moderator are?</mat-label>
|
||||||
|
<textarea matInput
|
||||||
|
formControlName="moderatorExpectations"
|
||||||
|
placeholder="Describe what you think a moderator should do"
|
||||||
|
rows="4"></textarea>
|
||||||
|
@if (form.controls.moderatorExpectations.invalid && form.controls.moderatorExpectations.touched) {
|
||||||
|
<mat-error>
|
||||||
|
@if (form.controls.moderatorExpectations.errors?.['required']) {
|
||||||
|
Moderator expectations are required
|
||||||
|
} @else if (form.controls.moderatorExpectations.errors?.['minlength']) {
|
||||||
|
Please provide more details (at least 10 characters)
|
||||||
|
}
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Additional Information -->
|
||||||
|
<mat-form-field appearance="fill" style="width: 100%;">
|
||||||
|
<mat-label>Additional Information (Optional)</mat-label>
|
||||||
|
<textarea matInput
|
||||||
|
formControlName="additionalInfo"
|
||||||
|
placeholder="Any additional information you'd like to share"
|
||||||
|
rows="4"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-raised-button (click)="onSubmit()"
|
||||||
|
[disabled]="isFormInvalid() || isSubmitting()">
|
||||||
|
Submit Application
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation dots -->
|
||||||
|
@if (totalPages.length > 1) {
|
||||||
|
<div class="form-navigation">
|
||||||
|
<button mat-icon-button class="nav-button" (click)="previousPage()" [disabled]="isFirstPage()">
|
||||||
|
<mat-icon>navigate_before</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@for (i of totalPages; track i) {
|
||||||
|
<div
|
||||||
|
class="nav-dot"
|
||||||
|
[class.active]="i === currentPageIndex"
|
||||||
|
(click)="goToPage(i)">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button mat-icon-button class="nav-button" (click)="nextPage()" [disabled]="isLastPage()">
|
||||||
|
<mat-icon>navigate_next</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
} @else {
|
||||||
|
<section class="darkmodeSection staff-application-container">
|
||||||
|
<div>
|
||||||
|
<h1>Staff applications closed</h1>
|
||||||
|
<p class="center">Staff applications are closed at this time. Please keep an eye on our announcement channel
|
||||||
|
in Discord to
|
||||||
|
see
|
||||||
|
when it opens!</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staff-application-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formPage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
animation: fadeIn 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-navigation {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: auto;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
color: #1f9bde;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pages {
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
max-width: 75ch;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valid-email {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #4CAF50;
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid-email {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #af4c4c;
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.valid-email mat-icon {
|
||||||
|
color: #4CAF50;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valid-email span {
|
||||||
|
color: #4CAF50;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field {
|
||||||
|
margin: 16px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-error {
|
||||||
|
color: #f44336;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-container {
|
||||||
|
margin: 16px 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: block;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.days-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-chip {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: #1f9bde;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
Renderer2,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||||
|
import {ApplicationsService, EmailEntry, MailService, StaffApplication} from '@api';
|
||||||
|
import {HeaderComponent} from '@header/header.component';
|
||||||
|
import {NgOptimizedImage} from '@angular/common';
|
||||||
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
|
import {MatIconModule} from '@angular/material/icon';
|
||||||
|
import {AuthService} from '@services/auth.service';
|
||||||
|
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
|
||||||
|
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||||
|
import {MatSelectModule} from '@angular/material/select';
|
||||||
|
import {MatInputModule} from '@angular/material/input';
|
||||||
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
|
import {VerifyMailDialogComponent} from '@pages/forms/verify-mail-dialog/verify-mail-dialog.component';
|
||||||
|
import {Router} from '@angular/router';
|
||||||
|
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||||
|
import {MatDatepickerModule} from '@angular/material/datepicker';
|
||||||
|
import {MatNativeDateModule} from '@angular/material/core';
|
||||||
|
import {MatChipsModule} from '@angular/material/chips';
|
||||||
|
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-staff-application',
|
||||||
|
imports: [
|
||||||
|
HeaderComponent,
|
||||||
|
NgOptimizedImage,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatInputModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatNativeDateModule,
|
||||||
|
MatChipsModule
|
||||||
|
],
|
||||||
|
templateUrl: './staff-application.component.html',
|
||||||
|
styleUrl: './staff-application.component.scss'
|
||||||
|
})
|
||||||
|
export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||||
|
|
||||||
|
private mailService = inject(MailService);
|
||||||
|
private matSnackBar = inject(MatSnackBar);
|
||||||
|
public authService = inject(AuthService);
|
||||||
|
public staffApplicationService = inject(ApplicationsService)
|
||||||
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
private boundHandleResize: any;
|
||||||
|
protected isSubmitting = signal<boolean>(false);
|
||||||
|
|
||||||
|
protected form: FormGroup<StaffApplicationForm>;
|
||||||
|
private emails = signal<EmailEntry[]>([]);
|
||||||
|
protected verifiedEmails = computed(() => this.emails()
|
||||||
|
.filter(email => email.verified)
|
||||||
|
.map(email => email.email.toLowerCase()));
|
||||||
|
protected emailIsValid = signal<boolean>(false);
|
||||||
|
protected dialog = inject(MatDialog);
|
||||||
|
protected availableDays: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
|
protected selectedDays: string[] = [];
|
||||||
|
protected userTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
protected staffApplicationsIsOpen = signal<boolean>(false)
|
||||||
|
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private elementRef: ElementRef,
|
||||||
|
private renderer: Renderer2
|
||||||
|
) {
|
||||||
|
const staffApplication: StaffApplicationForm = {
|
||||||
|
email: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required, Validators.email, Validators.maxLength(320)]
|
||||||
|
}),
|
||||||
|
age: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required, Validators.min(13), Validators.pattern('^[0-9]*$')]
|
||||||
|
}),
|
||||||
|
discordUsername: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required, Validators.maxLength(32)]
|
||||||
|
}),
|
||||||
|
meetsRequirements: new FormControl(false, {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.requiredTrue]
|
||||||
|
}),
|
||||||
|
pronouns: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.maxLength(32)]
|
||||||
|
}),
|
||||||
|
joinDate: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required]
|
||||||
|
}),
|
||||||
|
weeklyPlaytime: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required, Validators.min(1), Validators.pattern('^[0-9]*$')]
|
||||||
|
}),
|
||||||
|
availableDays: new FormControl([], {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required]
|
||||||
|
}),
|
||||||
|
availableTimes: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required, Validators.maxLength(900)]
|
||||||
|
}),
|
||||||
|
previousExperience: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(4000)]
|
||||||
|
}),
|
||||||
|
pluginExperience: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(4000)]
|
||||||
|
}),
|
||||||
|
moderatorExpectations: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(4000)]
|
||||||
|
}),
|
||||||
|
additionalInfo: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.maxLength(4000)]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.form = new FormGroup(staffApplication);
|
||||||
|
|
||||||
|
this.mailService.getUserEmails().subscribe(emails => {
|
||||||
|
this.emails.set(emails);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.form.valueChanges.subscribe(() => {
|
||||||
|
if (this.verifiedEmails().includes(this.form.getRawValue().email.toLowerCase())) {
|
||||||
|
this.emailIsValid.set(true);
|
||||||
|
} else {
|
||||||
|
this.emailIsValid.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
computed(() => {
|
||||||
|
if (this.verifiedEmails().length > 0) {
|
||||||
|
this.form.get('email')?.setValue(this.verifiedEmails()[0]);
|
||||||
|
this.emailIsValid.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.staffApplicationService.getStaffApplicationsIsOpen().subscribe(isOpen => {
|
||||||
|
this.staffApplicationsIsOpen.set(isOpen)
|
||||||
|
})
|
||||||
|
const uuid = this.authService.getUuid();
|
||||||
|
if (uuid === null) {
|
||||||
|
alert('Error retrieving token, please relog on the website and try again')
|
||||||
|
throw new Error('JWT subject is null, are you logged in?');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit() {
|
||||||
|
this.setupResizeObserver();
|
||||||
|
this.updateContainerHeight();
|
||||||
|
|
||||||
|
this.boundHandleResize = this.handleResize.bind(this);
|
||||||
|
window.addEventListener('resize', this.boundHandleResize);
|
||||||
|
|
||||||
|
setTimeout(() => this.updateContainerHeight(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
if (this.resizeObserver) {
|
||||||
|
this.resizeObserver.disconnect();
|
||||||
|
this.resizeObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.boundHandleResize) {
|
||||||
|
window.removeEventListener('resize', this.boundHandleResize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleResize() {
|
||||||
|
this.updateContainerHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupResizeObserver() {
|
||||||
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
|
this.updateContainerHeight();
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerElement = document.querySelector('app-header');
|
||||||
|
if (headerElement) {
|
||||||
|
this.resizeObserver.observe(headerElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
const footerElement = document.querySelector('footer');
|
||||||
|
if (footerElement) {
|
||||||
|
this.resizeObserver.observe(footerElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateContainerHeight() {
|
||||||
|
const headerElement = document.querySelector('app-header');
|
||||||
|
const footerElement = document.querySelector('footer');
|
||||||
|
|
||||||
|
const container = this.elementRef.nativeElement.querySelector('.staff-application-container');
|
||||||
|
|
||||||
|
if (headerElement && footerElement && container) {
|
||||||
|
const headerHeight = headerElement.getBoundingClientRect().height;
|
||||||
|
const footerHeight = footerElement.getBoundingClientRect().height;
|
||||||
|
|
||||||
|
const calculatedHeight = `calc(100vh - ${headerHeight}px - ${footerHeight}px)`;
|
||||||
|
this.renderer.setStyle(container, 'min-height', calculatedHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSubmit() {
|
||||||
|
this.isSubmitting.set(true);
|
||||||
|
if (this.form === undefined) {
|
||||||
|
console.error('Form is undefined');
|
||||||
|
this.matSnackBar.open('An error occurred, please try again later')
|
||||||
|
this.isSubmitting.set(false);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.form.valid) {
|
||||||
|
this.sendForm()
|
||||||
|
} else {
|
||||||
|
// Mark all fields as touched to show validation errors
|
||||||
|
Object.keys(this.form.controls).forEach(field => {
|
||||||
|
const control = this.form.get(field);
|
||||||
|
control?.markAsTouched();
|
||||||
|
});
|
||||||
|
this.matSnackBar.open('Please fill out all required fields')
|
||||||
|
this.isSubmitting.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
isFormInvalid() {
|
||||||
|
return !this.form.valid
|
||||||
|
}
|
||||||
|
|
||||||
|
private router = inject(Router)
|
||||||
|
|
||||||
|
private sendForm() {
|
||||||
|
const staffApplication: StaffApplication = this.mapToStaffApplication(this.form.getRawValue());
|
||||||
|
|
||||||
|
this.staffApplicationService.submitStaffApplication(staffApplication).subscribe({
|
||||||
|
next: result => {
|
||||||
|
if (!result.verified_mail) {
|
||||||
|
this.isSubmitting.set(false);
|
||||||
|
this.matSnackBar.open('Your email has not been verified. Please verify your email before submitting.', 'Close', {
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.router.navigate(['/forms/sent'], {
|
||||||
|
state: {message: result.message}
|
||||||
|
}).then();
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.isSubmitting.set(false);
|
||||||
|
console.error('Error submitting application:', error);
|
||||||
|
this.matSnackBar.open('An error occurred while submitting your application. Please try again later.', 'Close', {
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public currentPageIndex: number = 0;
|
||||||
|
public totalPages: number[] = [0, 1, 2, 3, 4];
|
||||||
|
|
||||||
|
public goToPage(pageIndex: number): void {
|
||||||
|
if (pageIndex >= 0 && pageIndex < this.totalPages.length) {
|
||||||
|
this.currentPageIndex = pageIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public previousPage() {
|
||||||
|
this.goToPage(this.currentPageIndex - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public nextPage() {
|
||||||
|
this.goToPage(this.currentPageIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isFirstPage(): boolean {
|
||||||
|
return this.currentPageIndex === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isLastPage(): boolean {
|
||||||
|
return this.currentPageIndex === this.totalPages.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected validateMailOrNextPage() {
|
||||||
|
if (this.emailIsValid()) {
|
||||||
|
this.nextPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dialogRef = this.dialog.open(VerifyMailDialogComponent, {
|
||||||
|
data: {email: this.form.getRawValue().email},
|
||||||
|
});
|
||||||
|
dialogRef.afterClosed().subscribe(result => {
|
||||||
|
if (result === true) {
|
||||||
|
this.emailIsValid.set(true);
|
||||||
|
this.nextPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleDay(day: string) {
|
||||||
|
const availableDaysControl = this.form.get('availableDays');
|
||||||
|
const currentDays = [...(availableDaysControl?.value || [])];
|
||||||
|
|
||||||
|
if (currentDays.includes(day)) {
|
||||||
|
const index = currentDays.indexOf(day);
|
||||||
|
currentDays.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
currentDays.push(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
availableDaysControl?.setValue(currentDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToStaffApplication(formData: any): StaffApplication {
|
||||||
|
let joinDateString: string;
|
||||||
|
|
||||||
|
if (formData.joinDate instanceof Date) {
|
||||||
|
joinDateString = formData.joinDate.toISOString();
|
||||||
|
} else if (typeof formData.joinDate === 'string' && formData.joinDate.trim() !== '') {
|
||||||
|
const parsedDate = new Date(formData.joinDate);
|
||||||
|
if (isNaN(parsedDate.getTime())) {
|
||||||
|
throw new Error('Invalid date string');
|
||||||
|
}
|
||||||
|
joinDateString = parsedDate.toISOString();
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid date string');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: formData.email,
|
||||||
|
age: Number(formData.age),
|
||||||
|
discordUsername: formData.discordUsername,
|
||||||
|
meetsRequirements: formData.meetsRequirements,
|
||||||
|
pronouns: formData.pronouns || '',
|
||||||
|
joinDate: joinDateString,
|
||||||
|
weeklyPlaytime: Number(formData.weeklyPlaytime),
|
||||||
|
availableDays: formData.availableDays,
|
||||||
|
availableTimes: `Timezone: ${this.userTimezone}\nAvailable Times: ${formData.availableTimes}`,
|
||||||
|
previousExperience: formData.previousExperience,
|
||||||
|
pluginExperience: formData.pluginExperience,
|
||||||
|
moderatorExpectations: formData.moderatorExpectations,
|
||||||
|
additionalInfo: formData.additionalInfo || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StaffApplicationForm {
|
||||||
|
email: FormControl<string>;
|
||||||
|
age: FormControl<string>;
|
||||||
|
discordUsername: FormControl<string>;
|
||||||
|
meetsRequirements: FormControl<boolean>;
|
||||||
|
pronouns: FormControl<string>;
|
||||||
|
joinDate: FormControl<string>;
|
||||||
|
weeklyPlaytime: FormControl<string>;
|
||||||
|
availableDays: FormControl<string[]>;
|
||||||
|
availableTimes: FormControl<string>;
|
||||||
|
previousExperience: FormControl<string>;
|
||||||
|
pluginExperience: FormControl<string>;
|
||||||
|
moderatorExpectations: FormControl<string>;
|
||||||
|
additionalInfo: FormControl<string>;
|
||||||
|
}
|
||||||
@@ -142,7 +142,9 @@
|
|||||||
<a [id]="getCurrentPageId(['particles'])"
|
<a [id]="getCurrentPageId(['particles'])"
|
||||||
class="nav_link fake_link" [ngClass]="active">Special</a>
|
class="nav_link fake_link" [ngClass]="active">Special</a>
|
||||||
<ul class="dropdown">
|
<ul class="dropdown">
|
||||||
|
@if (hasAccess([PermissionClaim.HEAD_MOD])) {
|
||||||
<li class="nav_li"><a class="nav_link2" [routerLink]="['/particles']">Particles</a></li>
|
<li class="nav_li"><a class="nav_link2" [routerLink]="['/particles']">Particles</a></li>
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {AuthService} from '@services/auth.service';
|
|||||||
import {Subscription} from 'rxjs';
|
import {Subscription} from 'rxjs';
|
||||||
import {LoginDialogComponent} from '@shared-components/login/login.component';
|
import {LoginDialogComponent} from '@shared-components/login/login.component';
|
||||||
import {MatDialog} from '@angular/material/dialog';
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
|
import {PermissionClaim} from '@api';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -90,4 +91,6 @@ export class HeaderComponent implements OnDestroy {
|
|||||||
protected logout() {
|
protected logout() {
|
||||||
this.authService.logout()
|
this.authService.logout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected readonly PermissionClaim = PermissionClaim;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,11 @@
|
|||||||
</app-header>
|
</app-header>
|
||||||
<main>
|
<main>
|
||||||
<section id="scrollingpoint" style="background: #202020; text-align: center; padding: 80px 0;">
|
<section id="scrollingpoint" style="background: #202020; text-align: center; padding: 80px 0;">
|
||||||
<!-- TODO load player count from old api or backend?-->
|
@if (this.playerCount() === null) {
|
||||||
<h2 style="color: white;"><span class="player-count">Loading...</span></h2>
|
<h2 style="color: white;"><span class="player-count">Loading...</span></h2>
|
||||||
|
} @else {
|
||||||
|
<h2 style="color: white;"><span class="player-count">Current players online: {{ playerCount() }}</span></h2>
|
||||||
|
}
|
||||||
<h2 style="color: white;">Server IP: play.alttd.com</h2>
|
<h2 style="color: white;">Server IP: play.alttd.com</h2>
|
||||||
<div style="padding-top: 35px;">
|
<div style="padding-top: 35px;">
|
||||||
<app-copy-ip></app-copy-ip>
|
<app-copy-ip></app-copy-ip>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Component, OnInit} from '@angular/core';
|
import {Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
|
||||||
import {Title} from '@angular/platform-browser';
|
import {Title} from '@angular/platform-browser';
|
||||||
import {ALTITUDE_VERSION} from '@custom-types/constant';
|
import {ALTITUDE_VERSION} from '@custom-types/constant';
|
||||||
import {ScrollService} from '@services/scroll.service';
|
import {ScrollService} from '@services/scroll.service';
|
||||||
@@ -7,6 +7,16 @@ import {HeaderComponent} from '@header/header.component';
|
|||||||
import {CopyIpComponent} from '@shared-components/copy-ip/copy-ip.component';
|
import {CopyIpComponent} from '@shared-components/copy-ip/copy-ip.component';
|
||||||
import {RouterLink} from '@angular/router';
|
import {RouterLink} from '@angular/router';
|
||||||
import {JwtHelperService} from '@auth0/angular-jwt';
|
import {JwtHelperService} from '@auth0/angular-jwt';
|
||||||
|
import {interval, map} from 'rxjs';
|
||||||
|
import {HttpClient} from '@angular/common/http';
|
||||||
|
import {startWith} from 'rxjs/operators';
|
||||||
|
|
||||||
|
interface MinecraftServerStatus {
|
||||||
|
online?: boolean;
|
||||||
|
players?: {
|
||||||
|
online?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -24,9 +34,16 @@ import {JwtHelperService} from '@auth0/angular-jwt';
|
|||||||
templateUrl: './home.component.html',
|
templateUrl: './home.component.html',
|
||||||
styleUrl: './home.component.scss'
|
styleUrl: './home.component.scss'
|
||||||
})
|
})
|
||||||
export class HomeComponent implements OnInit {
|
export class HomeComponent implements OnInit, OnDestroy {
|
||||||
constructor(private titleService: Title, public scrollService: ScrollService) {
|
private httpClient = inject(HttpClient);
|
||||||
}
|
private titleService = inject(Title);
|
||||||
|
public scrollService = inject(ScrollService);
|
||||||
|
public playerCount = signal<null | number>(null);
|
||||||
|
private playerCountUpdateInterval = interval(60000).pipe(
|
||||||
|
startWith(() => 0),
|
||||||
|
).subscribe(() => {
|
||||||
|
this.updatePlayerCount();
|
||||||
|
});
|
||||||
|
|
||||||
private slides: string[] = ["/public/img/backgrounds/caruselimage2.png",
|
private slides: string[] = ["/public/img/backgrounds/caruselimage2.png",
|
||||||
"/public/img/backgrounds/caruselimage4.png",
|
"/public/img/backgrounds/caruselimage4.png",
|
||||||
@@ -44,6 +61,10 @@ export class HomeComponent implements OnInit {
|
|||||||
this.randomizeSlides()
|
this.randomizeSlides()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.playerCountUpdateInterval.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
private randomizeSlides(): void {
|
private randomizeSlides(): void {
|
||||||
const array = [...this.slides];
|
const array = [...this.slides];
|
||||||
|
|
||||||
@@ -108,5 +129,15 @@ export class HomeComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updatePlayerCount() {
|
||||||
|
this.httpClient.get<MinecraftServerStatus>('https://api.mcsrvstat.us/2/play.alttd.com').pipe(
|
||||||
|
map(response => {
|
||||||
|
if (response.online && response.players && response.players.online) {
|
||||||
|
this.playerCount.set(response.players.online);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly ALTITUDE_VERSION = ALTITUDE_VERSION;
|
protected readonly ALTITUDE_VERSION = ALTITUDE_VERSION;
|
||||||
}
|
}
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
<h2 mat-dialog-title>Edit {{ data.punishment.type }} #{{ data.punishment.id }}</h2>
|
||||||
|
<div mat-dialog-content>
|
||||||
|
<div class="dialog-content">
|
||||||
|
<mat-form-field appearance="fill">
|
||||||
|
<mat-label>Reason</mat-label>
|
||||||
|
<input matInput type="text" [(ngModel)]="reason" placeholder="Enter reason"/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-checkbox [(ngModel)]="isPermanent"><span style="color: black">Permanent</span></mat-checkbox>
|
||||||
|
|
||||||
|
@if (!isPermanent) {
|
||||||
|
<div class="datetime-container">
|
||||||
|
<mat-form-field appearance="fill" class="date-field">
|
||||||
|
<mat-label>Expiry Date</mat-label>
|
||||||
|
<input matInput [matDatepicker]="dateTimePicker" [(ngModel)]="expiryDate" [disabled]="isPermanent">
|
||||||
|
<mat-hint>MM/DD/YYYY</mat-hint>
|
||||||
|
<mat-datepicker-toggle matIconSuffix [for]="dateTimePicker"></mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #dateTimePicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="fill" class="time-field">
|
||||||
|
<mat-label>Expiry Time</mat-label>
|
||||||
|
<input matInput type="time" [(ngModel)]="expiryTime" [disabled]="isPermanent">
|
||||||
|
<mat-hint>HH:MM (24-hour)</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
<p>Time in timezone: {{ getTimezone() }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<div class="error">{{ errorMessage() }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div mat-dialog-actions align="end">
|
||||||
|
<button mat-button color="warn" (click)="onRemove()" [disabled]="isBusy()">Remove</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="onUpdate()" [disabled]="isBusy()">Update</button>
|
||||||
|
<button mat-button (click)="onCancel()" [disabled]="isBusy()">Cancel</button>
|
||||||
|
</div>
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
.dialog-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datetime-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.date-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-field {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #beb8b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
import {Component, Inject, inject, signal} from '@angular/core';
|
||||||
|
import {FormsModule} from '@angular/forms';
|
||||||
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
|
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||||
|
import {MatInput, MatLabel} from '@angular/material/input';
|
||||||
|
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||||
|
import {
|
||||||
|
MAT_DIALOG_DATA,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogRef,
|
||||||
|
MatDialogTitle
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import {HistoryService, PunishmentHistory} from '@api';
|
||||||
|
import {firstValueFrom} from 'rxjs';
|
||||||
|
import {MatDatepickerModule} from '@angular/material/datepicker';
|
||||||
|
|
||||||
|
interface EditPunishmentData {
|
||||||
|
punishment: PunishmentHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-edit-punishment-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInput,
|
||||||
|
MatLabel,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogContent,
|
||||||
|
MatDialogActions,
|
||||||
|
MatDatepickerModule,
|
||||||
|
],
|
||||||
|
templateUrl: './edit-punishment-dialog.component.html',
|
||||||
|
styleUrl: './edit-punishment-dialog.component.scss'
|
||||||
|
})
|
||||||
|
export class EditPunishmentDialogComponent {
|
||||||
|
// Form model
|
||||||
|
protected reason: string = '';
|
||||||
|
protected isPermanent: boolean = false;
|
||||||
|
protected expiresAt: Date;
|
||||||
|
protected expiryDate: Date | null = null;
|
||||||
|
protected expiryTime: string = '';
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
protected isBusy = signal<boolean>(false);
|
||||||
|
protected errorMessage = signal<string | null>(null);
|
||||||
|
|
||||||
|
private historyApi = inject(HistoryService);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public dialogRef: MatDialogRef<EditPunishmentDialogComponent, PunishmentHistory | { removed: true } | null>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: EditPunishmentData
|
||||||
|
) {
|
||||||
|
const punishment = data.punishment;
|
||||||
|
this.reason = punishment.reason ?? '';
|
||||||
|
this.isPermanent = punishment.expiryTime <= 0;
|
||||||
|
this.expiresAt = new Date(punishment.expiryTime);
|
||||||
|
|
||||||
|
if (this.expiresAt && !isNaN(this.expiresAt.getTime())) {
|
||||||
|
this.expiryDate = new Date(this.expiresAt);
|
||||||
|
|
||||||
|
const hours = this.expiresAt.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = this.expiresAt.getMinutes().toString().padStart(2, '0');
|
||||||
|
this.expiryTime = `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancel(): void {
|
||||||
|
if (this.isBusy()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dialogRef.close(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeUntilMs(): number {
|
||||||
|
if (this.isPermanent) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.expiryDate) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedDateTime = new Date(this.expiryDate);
|
||||||
|
|
||||||
|
if (this.expiryTime) {
|
||||||
|
const [hours, minutes] = this.expiryTime.split(':').map(Number);
|
||||||
|
combinedDateTime.setHours(hours, minutes, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.expiresAt = combinedDateTime;
|
||||||
|
|
||||||
|
const ms = combinedDateTime.getTime();
|
||||||
|
return isNaN(ms) ? -1 : ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(): void {
|
||||||
|
const punishment = this.data.punishment;
|
||||||
|
if (!window.confirm('Are you sure you want to update this punishment?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isBusy.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
|
||||||
|
const updates: Array<Promise<PunishmentHistory>> = [] as any;
|
||||||
|
|
||||||
|
if ((this.reason ?? '') !== (punishment.reason ?? '')) {
|
||||||
|
console.log('Changing reason to ', this.reason, ' from ', punishment.reason, '')
|
||||||
|
updates.push(firstValueFrom(this.historyApi.updatePunishmentReason(punishment.type, punishment.id, this.reason)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUntil = this.computeUntilMs();
|
||||||
|
if (newUntil !== punishment.expiryTime) {
|
||||||
|
console.log('Changing until to ', newUntil, ' from ', punishment.expiryTime, '')
|
||||||
|
updates.push(firstValueFrom(this.historyApi.updatePunishmentUntil(punishment.type, punishment.id, newUntil)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
this.isBusy.set(false);
|
||||||
|
this.dialogRef.close(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(updates)
|
||||||
|
.then(results => {
|
||||||
|
const updated = results[results.length - 1];
|
||||||
|
this.isBusy.set(false);
|
||||||
|
this.dialogRef.close(updated);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
this.errorMessage.set('Failed to update punishment');
|
||||||
|
this.isBusy.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onRemove(): void {
|
||||||
|
const punishment = this.data.punishment;
|
||||||
|
if (!window.confirm('Are you sure you want to remove this punishment?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isBusy.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
|
||||||
|
this.historyApi.removePunishment(punishment.type as any, punishment.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.isBusy.set(false);
|
||||||
|
this.dialogRef.close({removed: true});
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error(err);
|
||||||
|
this.errorMessage.set('Failed to remove punishment');
|
||||||
|
this.isBusy.set(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimezone() {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ export class HistoryFormatService {
|
|||||||
|
|
||||||
public getAvatarUrl(entry: string, size: string = '25'): string {
|
public getAvatarUrl(entry: string, size: string = '25'): string {
|
||||||
let uuid = entry.replace('-', '');
|
let uuid = entry.replace('-', '');
|
||||||
if (uuid === 'C') {
|
if (uuid === 'C' || uuid === 'Console' || uuid === '[Console]') {
|
||||||
uuid = "f78a4d8dd51b4b3998a3230f2de0c670"
|
uuid = "f78a4d8dd51b4b3998a3230f2de0c670"
|
||||||
}
|
}
|
||||||
return `https://crafatar.com/avatars/${uuid}?size=${size}&overlay`;
|
return `https://crafatar.com/avatars/${uuid}?size=${size}&overlay`;
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
<th class="historyReason">Reason</th>
|
<th class="historyReason">Reason</th>
|
||||||
<th class="historyDate">Date</th>
|
<th class="historyDate">Date</th>
|
||||||
<th class="historyDate">Expires</th>
|
<th class="historyDate">Expires</th>
|
||||||
|
@if (canEdit()) {
|
||||||
|
<th class="historyActions"></th>
|
||||||
|
}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
|
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
|
||||||
<div class="playerContainer">
|
<div class="playerContainer">
|
||||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25" height="25"
|
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25"
|
||||||
|
height="25"
|
||||||
alt="{{entry.punishedBy}}'s Minecraft skin">
|
alt="{{entry.punishedBy}}'s Minecraft skin">
|
||||||
<span>{{ entry.punishedBy }}</span>
|
<span>{{ entry.punishedBy }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,6 +50,11 @@
|
|||||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||||
{{ this.historyFormat.getExpiredTime(entry) }}
|
{{ this.historyFormat.getExpiredTime(entry) }}
|
||||||
</td>
|
</td>
|
||||||
|
@if (canEdit()) {
|
||||||
|
<td class="historyActions" (click)="openEdit(entry)">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -111,3 +111,8 @@ img {
|
|||||||
.historyDate {
|
.historyDate {
|
||||||
width: 170px;
|
width: 170px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.historyActions {
|
||||||
|
width: 100px;
|
||||||
|
padding: 0 10px 0 10px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
import {Component, EventEmitter, Input, OnChanges, OnInit, Output} from '@angular/core';
|
import {Component, EventEmitter, inject, Input, OnChanges, OnInit, Output} from '@angular/core';
|
||||||
import {BASE_PATH, HistoryService, PunishmentHistory} from '@api';
|
import {HistoryService, PunishmentHistory} from '@api';
|
||||||
import {catchError, map, Observable, shareReplay} from 'rxjs';
|
import {catchError, map, Observable, shareReplay} from 'rxjs';
|
||||||
import {NgOptimizedImage} from '@angular/common';
|
import {NgOptimizedImage} from '@angular/common';
|
||||||
import {CookieService} from 'ngx-cookie-service';
|
import {CookieService} from 'ngx-cookie-service';
|
||||||
import {RemoveTrailingPeriodPipe} from '@pipes/RemoveTrailingPeriodPipe';
|
import {RemoveTrailingPeriodPipe} from '@pipes/RemoveTrailingPeriodPipe';
|
||||||
import {HttpErrorResponse} from '@angular/common/http';
|
import {HttpErrorResponse} from '@angular/common/http';
|
||||||
import {environment} from '@environment';
|
|
||||||
import {HistoryFormatService} from '../history-format.service';
|
import {HistoryFormatService} from '../history-format.service';
|
||||||
import {SearchParams} from '../search-terms';
|
import {SearchParams} from '../search-terms';
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
|
import {AuthService} from '@services/auth.service';
|
||||||
|
import {MatDialog} from '@angular/material/dialog';
|
||||||
|
import {EditPunishmentDialogComponent} from '../edit-punishment-dialog/edit-punishment-dialog.component';
|
||||||
|
import {MatIconModule} from '@angular/material/icon';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-history',
|
selector: 'app-history',
|
||||||
imports: [
|
imports: [
|
||||||
NgOptimizedImage,
|
NgOptimizedImage,
|
||||||
RemoveTrailingPeriodPipe
|
RemoveTrailingPeriodPipe,
|
||||||
|
MatIconModule,
|
||||||
],
|
],
|
||||||
templateUrl: './history.component.html',
|
templateUrl: './history.component.html',
|
||||||
styleUrl: './history.component.scss',
|
styleUrl: './history.component.scss',
|
||||||
providers: [
|
providers: [
|
||||||
CookieService,
|
CookieService,
|
||||||
{provide: BASE_PATH, useValue: environment.apiUrl}
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class HistoryComponent implements OnInit, OnChanges {
|
export class HistoryComponent implements OnInit, OnChanges {
|
||||||
@@ -37,8 +40,11 @@ export class HistoryComponent implements OnInit, OnChanges {
|
|||||||
|
|
||||||
public history: PunishmentHistory[] = []
|
public history: PunishmentHistory[] = []
|
||||||
|
|
||||||
constructor(private historyApi: HistoryService, public historyFormat: HistoryFormatService, private router: Router) {
|
private historyApi: HistoryService = inject(HistoryService)
|
||||||
}
|
public historyFormat: HistoryFormatService = inject(HistoryFormatService)
|
||||||
|
private router: Router = inject(Router)
|
||||||
|
private authService: AuthService = inject(AuthService)
|
||||||
|
private dialog: MatDialog = inject(MatDialog)
|
||||||
|
|
||||||
ngOnChanges(): void {
|
ngOnChanges(): void {
|
||||||
this.reloadHistory();
|
this.reloadHistory();
|
||||||
@@ -103,4 +109,21 @@ export class HistoryComponent implements OnInit, OnChanges {
|
|||||||
public showDetailedPunishment(entry: PunishmentHistory) {
|
public showDetailedPunishment(entry: PunishmentHistory) {
|
||||||
this.router.navigate([`bans/${entry.type}/${entry.id}`]).then();
|
this.router.navigate([`bans/${entry.type}/${entry.id}`]).then();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public canEdit(): boolean {
|
||||||
|
return this.authService.hasAccess(['SCOPE_head_mod']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public openEdit(punishment: PunishmentHistory) {
|
||||||
|
if (!this.canEdit()) return;
|
||||||
|
const ref = this.dialog.open(EditPunishmentDialogComponent, {
|
||||||
|
data: {punishment},
|
||||||
|
width: '500px',
|
||||||
|
});
|
||||||
|
ref.afterClosed().subscribe(result => {
|
||||||
|
if (result) {
|
||||||
|
this.reloadHistory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="columnContainer">
|
<div class="columnContainer">
|
||||||
<div class="columnParagraph">
|
<div class="columnParagraph">
|
||||||
<h2 style="text-align: center;">Weekly Vote Crate</h2>
|
<h2 style="text-align: center;">Weekly Vote Crate</h2>
|
||||||
<p>Vote at least 36 times in a week (thats 6 times a day for 6 days!) to get a Weekly Crate Key! Weekly
|
<p>Vote at least 28 times in a week (that's 4 times a day for 6 days!) to get a Weekly Crate Key! Weekly
|
||||||
Crates have higher value rewards, exclusive collectibles, and items with normally impossible
|
Crates have higher value rewards, exclusive collectibles, and items with normally impossible
|
||||||
enchantments.</p>
|
enchantments.</p>
|
||||||
<p><span style="font-family: 'opensans-bold', sans-serif;">Please note:</span> Weeks reset on Saturday at
|
<p><span style="font-family: 'opensans-bold', sans-serif;">Please note:</span> Weeks reset on Saturday at
|
||||||
@@ -41,78 +41,27 @@
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<section class="voteSection">
|
<section class="voteSection">
|
||||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
<div class="container voteContainer">
|
||||||
|
@for (voteSite of Object.keys(voteSites); track voteSite) {
|
||||||
<div class="vote">
|
<div class="vote">
|
||||||
<h2>MinecraftServers</h2>
|
<h2>{{ voteSite }}</h2>
|
||||||
<div>
|
<div>
|
||||||
<a onclick="clickVote('vote1');" oncontextmenu="clickVote('vote1');" target="_blank" rel="noopener"
|
<a (click)="clickVote(voteSite)" (contextmenu)="clickVote(voteSite); $event.preventDefault()"
|
||||||
href="https://minecraftservers.org/vote/284208">
|
target="_blank" rel="noopener"
|
||||||
<div class="button-outer">
|
[href]="voteSites[voteSite]">
|
||||||
<span id="vote1" class="button-inner">Vote!</span>
|
<div class=button-outer [class.not-available-button-outer]="!canVote(voteSite)"
|
||||||
|
[class.available-button-outer]="canVote(voteSite)">
|
||||||
|
<span class="button-inner">{{ getVoteText(voteSite) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@if (voteStats) {
|
||||||
|
<div class="voteStats">
|
||||||
|
<p>Last voted: {{ getLastVoted(voteSite) | TimeAgo: true }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="vote">
|
}
|
||||||
<h2>TopMinecraftServers</h2>
|
|
||||||
<div>
|
|
||||||
<a (click)="clickVote('vote2')" oncontextmenu="clickVote('vote2'); return false;" target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
href="https://topminecraftservers.org/vote/4906">
|
|
||||||
<div class="button-outer">
|
|
||||||
<span id="vote2" class="button-inner">Vote!</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="vote">
|
|
||||||
<h2>MCSL</h2>
|
|
||||||
<div>
|
|
||||||
<a (click)="clickVote('vote3')" oncontextmenu="clickVote('vote3'); return false;" target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
href="https://minecraft-server-list.com/server/298238/vote/">
|
|
||||||
<div class="button-outer">
|
|
||||||
<span id="vote3" class="button-inner">Vote!</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="vote">
|
|
||||||
<h2>Minecraft-Server</h2>
|
|
||||||
<div>
|
|
||||||
<a (click)="clickVote('vote4')" oncontextmenu="clickVote('vote4'); return false;" target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
href="https://minecraft-server.net/vote/Altitude/">
|
|
||||||
<div class="button-outer">
|
|
||||||
<span id="vote4" class="button-inner">Vote!</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="vote">
|
|
||||||
<h2>PlanetMinecraft</h2>
|
|
||||||
<div>
|
|
||||||
<a (click)="clickVote('vote5')" oncontextmenu="clickVote('vote5'); return false;" target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
href="https://www.planetminecraft.com/server/alttd/vote/">
|
|
||||||
<div class="button-outer">
|
|
||||||
<span id="vote5" class="button-inner">Vote!</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="vote">
|
|
||||||
<h2>Minecraft-MP</h2>
|
|
||||||
<div>
|
|
||||||
<a (click)="clickVote('vote6')" oncontextmenu="clickVote('vote6'); return false;" target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
href="https://minecraft-mp.com/server/98955/vote/">
|
|
||||||
<div class="button-outer">
|
|
||||||
<span id="vote6" class="button-inner">Vote!</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="darkmodeSection">
|
<section class="darkmodeSection">
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voteContainer {
|
||||||
|
padding: 50px 0 0 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.voteSection {
|
.voteSection {
|
||||||
background-color: var(--link-color);
|
background-color: var(--link-color);
|
||||||
transition: 0.5s ease;
|
transition: 0.5s ease;
|
||||||
@@ -36,3 +41,11 @@
|
|||||||
color: black;
|
color: black;
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.available-button-outer {
|
||||||
|
background-color: #4caf50 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-available-button-outer {
|
||||||
|
background-color: var(--white) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,102 @@
|
|||||||
import {Component} from '@angular/core';
|
import {Component, effect, inject, OnDestroy, OnInit} from '@angular/core';
|
||||||
import {ScrollService} from '@services/scroll.service';
|
import {ScrollService} from '@services/scroll.service';
|
||||||
|
|
||||||
import {HeaderComponent} from '@header/header.component';
|
import {HeaderComponent} from '@header/header.component';
|
||||||
|
import {SiteService, VoteData} from '@api';
|
||||||
|
import {AuthService} from '@services/auth.service';
|
||||||
|
import {interval, Subscription} from 'rxjs';
|
||||||
|
import {TimeAgoPipe} from '@pipes/TimeAgoPipe';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-vote',
|
selector: 'app-vote',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
HeaderComponent
|
HeaderComponent,
|
||||||
|
TimeAgoPipe
|
||||||
],
|
],
|
||||||
templateUrl: './vote.component.html',
|
templateUrl: './vote.component.html',
|
||||||
styleUrl: './vote.component.scss'
|
styleUrl: './vote.component.scss'
|
||||||
})
|
})
|
||||||
export class VoteComponent {
|
export class VoteComponent implements OnInit, OnDestroy {
|
||||||
constructor(public scrollService: ScrollService) {
|
private readonly defaultVoteMessage = 'Vote!';
|
||||||
|
private readonly clickedVoteMessage = 'Clicked!';
|
||||||
|
|
||||||
|
private voteMessages: { [key: string]: string } = {}
|
||||||
|
private refreshSubscription: Subscription | null = null;
|
||||||
|
|
||||||
|
protected readonly voteSites: { [key: string]: string } = {
|
||||||
|
'PlanetMinecraft': 'https://www.planetminecraft.com/server/alttd/vote/',
|
||||||
|
'TopMinecraftServers': 'https://topminecraftservers.org/vote/4906',
|
||||||
|
'Minecraft-Server': 'https://minecraft-server.net/vote/Altitude/',
|
||||||
|
'MinecraftServers': 'https://minecraftservers.org/vote/284208',
|
||||||
|
'MCSL': 'https://minecraft-server-list.com/server/298238/vote/',
|
||||||
|
'Minecraft-MP': 'https://minecraft-mp.com/server/98955/vote/',
|
||||||
}
|
}
|
||||||
|
|
||||||
voteMessage: string = '';
|
protected scrollService: ScrollService = inject(ScrollService);
|
||||||
|
protected siteService = inject(SiteService)
|
||||||
|
protected authService = inject(AuthService)
|
||||||
|
|
||||||
|
protected voteStats: VoteData | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (this.authService.isAuthenticated$()) {
|
||||||
|
this.loadVoteStats();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.refreshSubscription = interval(300000).subscribe(() => {
|
||||||
|
this.loadVoteStats();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.refreshSubscription?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
clickVote(id: string) {
|
clickVote(id: string) {
|
||||||
this.voteMessage = 'Clicked!';
|
this.voteMessages[id] = this.clickedVoteMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
getVoteText(id: string) {
|
||||||
|
return this.voteMessages[id] || this.defaultVoteMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadVoteStats(): void {
|
||||||
|
if (!this.authService.isAuthenticated$()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.siteService.getVoteStats().subscribe(voteStats => {
|
||||||
|
this.voteStats = voteStats;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getLastVoted(id: string): Date | null {
|
||||||
|
if (!this.voteStats) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const filteredVoteInfo = this.voteStats.allVoteInfo
|
||||||
|
.filter(voteInfo => voteInfo.siteName === id);
|
||||||
|
if (filteredVoteInfo.length !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(filteredVoteInfo[0].lastVoteTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly Object = Object;
|
||||||
|
|
||||||
|
canVote(voteSite: string) {
|
||||||
|
if (!this.voteStats) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const now: Date = new Date();
|
||||||
|
return (
|
||||||
|
this.voteStats.allVoteInfo.some(voteInfo => voteInfo.siteName === voteSite
|
||||||
|
&& voteInfo.lastVoteTimestamp - now.getTime() < 86400000)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {Pipe, PipeTransform} from '@angular/core';
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'TimeAgo',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class TimeAgoPipe implements PipeTransform {
|
||||||
|
transform(value: Date | string | number | null, short?: boolean): string {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||||
|
|
||||||
|
if (seconds < 60) {
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
let returnText = 'ago';
|
||||||
|
|
||||||
|
const allMinutes = Math.floor(seconds / 60);
|
||||||
|
const minutes = allMinutes % 60;
|
||||||
|
if (short) {
|
||||||
|
returnText = `${minutes}m ${returnText}`
|
||||||
|
} else {
|
||||||
|
returnText = `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ${returnText}`
|
||||||
|
}
|
||||||
|
if (allMinutes < 60) {
|
||||||
|
return returnText
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const allHours = Math.floor(allMinutes / 60);
|
||||||
|
const hours = allHours % 24;
|
||||||
|
if (short) {
|
||||||
|
returnText = `${hours}h ${returnText}`
|
||||||
|
} else {
|
||||||
|
returnText = `${hours} ${hours === 1 ? 'hour' : 'hours'} ${returnText}`
|
||||||
|
}
|
||||||
|
if (allHours < 24) {
|
||||||
|
return returnText
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(allHours / 24);
|
||||||
|
if (short) {
|
||||||
|
returnText = `${days}d ${returnText}`
|
||||||
|
} else {
|
||||||
|
returnText = `${days} ${days === 1 ? 'day' : 'days'} ${returnText}`
|
||||||
|
}
|
||||||
|
return returnText
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Check if the user is authenticated
|
* Check if the user is authenticated
|
||||||
*/
|
*/
|
||||||
public checkAuthStatus(): boolean {
|
private checkAuthStatus(): boolean {
|
||||||
const jwt = this.getJwt();
|
const jwt = this.getJwt();
|
||||||
if (!jwt) {
|
if (!jwt) {
|
||||||
console.log("No JWT found");
|
console.log("No JWT found");
|
||||||
@@ -82,7 +82,6 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const claims = this.extractJwtClaims(jwt);
|
const claims = this.extractJwtClaims(jwt);
|
||||||
console.log("User claims: ", claims);
|
|
||||||
this.userClaimsSubject.set(claims);
|
this.userClaimsSubject.set(claims);
|
||||||
this.isAuthenticatedSubject.set(true);
|
this.isAuthenticatedSubject.set(true);
|
||||||
if (this.username() == null) {
|
if (this.username() == null) {
|
||||||
@@ -109,7 +108,6 @@ export class AuthService {
|
|||||||
localStorage.setItem('jwt', jwt);
|
localStorage.setItem('jwt', jwt);
|
||||||
|
|
||||||
const claims = this.extractJwtClaims(jwt);
|
const claims = this.extractJwtClaims(jwt);
|
||||||
console.log("Saving user claims: ", claims);
|
|
||||||
this.userClaimsSubject.set(claims);
|
this.userClaimsSubject.set(claims);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const ALTITUDE_VERSION = "1.21"
|
export const ALTITUDE_VERSION = "1.21.8"
|
||||||
|
|
||||||
export const enum THEME_MODE {
|
export const enum THEME_MODE {
|
||||||
LIGHT = 'theme-light',
|
LIGHT = 'theme-light',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
apiUrl: 'https://beta.alttd.com',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
apiUrl: 'http://localhost:8080',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
apiUrl: 'https://alttd.com',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
apiUrl: 'https://alttd.com',
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,18 +3,17 @@ import {AppComponent} from './app/app.component';
|
|||||||
import {provideRouter} from '@angular/router';
|
import {provideRouter} from '@angular/router';
|
||||||
import {routes} from './app/app.routes';
|
import {routes} from './app/app.routes';
|
||||||
import {provideHttpClient, withInterceptors} from '@angular/common/http';
|
import {provideHttpClient, withInterceptors} from '@angular/common/http';
|
||||||
import {BASE_PATH} from '@api';
|
|
||||||
import {environment} from '@environment';
|
|
||||||
import {authInterceptor} from '@services/AuthInterceptor';
|
import {authInterceptor} from '@services/AuthInterceptor';
|
||||||
|
import {provideNativeDateAdapter} from '@angular/material/core';
|
||||||
|
|
||||||
bootstrapApplication(AppComponent, {
|
bootstrapApplication(AppComponent, {
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
{provide: BASE_PATH, useValue: environment.apiUrl},
|
|
||||||
provideHttpClient(
|
provideHttpClient(
|
||||||
withInterceptors([authInterceptor])
|
withInterceptors([authInterceptor])
|
||||||
),
|
),
|
||||||
|
provideNativeDateAdapter()
|
||||||
]
|
]
|
||||||
}).catch(err => console.error(err));
|
}).catch(err => console.error(err));
|
||||||
|
|
||||||
|
|||||||
@@ -350,6 +350,15 @@ main .container {
|
|||||||
background: #8b8a8f;
|
background: #8b8a8f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-field mat-checkbox {
|
||||||
|
color: var(--font-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-checkbox .mdc-form-field label {
|
||||||
|
color: var(--font-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Darkmode sections
|
// Darkmode sections
|
||||||
.darkmodeSection {
|
.darkmodeSection {
|
||||||
background-color: var(--color-secondary);
|
background-color: var(--color-secondary);
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ sourceSets {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||||
implementation("io.swagger.core.v3:swagger-annotations:2.2.20")
|
implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
|
||||||
implementation("io.swagger.core.v3:swagger-models:2.2.8")
|
implementation("io.swagger.core.v3:swagger-models:2.2.37")
|
||||||
implementation("io.swagger.core.v3:swagger-core:2.2.8")
|
implementation("io.swagger.core.v3:swagger-core:2.2.37")
|
||||||
|
|
||||||
implementation("org.openapitools:jackson-databind-nullable:0.2.6")
|
implementation("org.openapitools:jackson-databind-nullable:0.2.6")
|
||||||
implementation("org.springframework.hateoas:spring-hateoas:2.2.0")
|
implementation("org.springframework.hateoas:spring-hateoas:2.2.0")
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ tags:
|
|||||||
description: All action related to appeals
|
description: All action related to appeals
|
||||||
- name: mail
|
- name: mail
|
||||||
description: All actions related to user email verification
|
description: All actions related to user email verification
|
||||||
|
- name: site
|
||||||
|
description: Actions related to small features on the site such as displaying vote stats or pt/rank stats
|
||||||
paths:
|
paths:
|
||||||
/api/team/{team}:
|
/api/team/{team}:
|
||||||
$ref: './schemas/team/team.yml#/getTeam'
|
$ref: './schemas/team/team.yml#/getTeam'
|
||||||
@@ -49,12 +51,22 @@ paths:
|
|||||||
$ref: './schemas/bans/bans.yml#/getAllHistoryForUUID'
|
$ref: './schemas/bans/bans.yml#/getAllHistoryForUUID'
|
||||||
/api/history/total:
|
/api/history/total:
|
||||||
$ref: './schemas/bans/bans.yml#/getTotalPunishments'
|
$ref: './schemas/bans/bans.yml#/getTotalPunishments'
|
||||||
|
/api/history/admin/{type}/{id}/reason:
|
||||||
|
$ref: './schemas/bans/bans.yml#/updatePunishmentReason'
|
||||||
|
/api/history/admin/{type}/{id}/until:
|
||||||
|
$ref: './schemas/bans/bans.yml#/updatePunishmentUntil'
|
||||||
|
/api/history/admin/{type}/{id}:
|
||||||
|
$ref: './schemas/bans/bans.yml#/removePunishment'
|
||||||
/api/appeal/update-mail:
|
/api/appeal/update-mail:
|
||||||
$ref: './schemas/forms/appeal/appeal.yml#/UpdateMail'
|
$ref: './schemas/forms/appeal/appeal.yml#/UpdateMail'
|
||||||
/api/appeal/minecraft-appeal:
|
/api/appeal/minecraft-appeal:
|
||||||
$ref: './schemas/forms/appeal/appeal.yml#/MinecraftAppeal'
|
$ref: './schemas/forms/appeal/appeal.yml#/MinecraftAppeal'
|
||||||
/api/appeal/discord-appeal:
|
/api/appeal/discord-appeal:
|
||||||
$ref: './schemas/forms/appeal/appeal.yml#/DiscordAppeal'
|
$ref: './schemas/forms/appeal/appeal.yml#/DiscordAppeal'
|
||||||
|
/api/apply/staff-application:
|
||||||
|
$ref: './schemas/forms/staff_apply/staff_apply.yml#/StaffApply'
|
||||||
|
/api/apply/staff-application-is-open:
|
||||||
|
$ref: './schemas/forms/staff_apply/staff_apply.yml#/StaffApplicationsIsOpen'
|
||||||
/api/login/requestNewUserLogin/{uuid}:
|
/api/login/requestNewUserLogin/{uuid}:
|
||||||
$ref: './schemas/login/login.yml#/RequestNewUserLogin'
|
$ref: './schemas/login/login.yml#/RequestNewUserLogin'
|
||||||
/api/login/userLogin/{code}:
|
/api/login/userLogin/{code}:
|
||||||
@@ -79,3 +91,5 @@ paths:
|
|||||||
$ref: './schemas/forms/mail/mail.yml#/DeleteEmail'
|
$ref: './schemas/forms/mail/mail.yml#/DeleteEmail'
|
||||||
/api/mail/list:
|
/api/mail/list:
|
||||||
$ref: './schemas/forms/mail/mail.yml#/GetEmails'
|
$ref: './schemas/forms/mail/mail.yml#/GetEmails'
|
||||||
|
/api/site/vote:
|
||||||
|
$ref: './schemas/site/vote.yml#/VoteStats'
|
||||||
|
|||||||
@@ -211,6 +211,73 @@ getAllHistoryForUUID:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/PunishmentHistoryList'
|
$ref: '#/components/schemas/PunishmentHistoryList'
|
||||||
|
updatePunishmentReason:
|
||||||
|
patch:
|
||||||
|
tags:
|
||||||
|
- history
|
||||||
|
summary: Update punishment reason
|
||||||
|
description: Updates the reason for a specific punishment history entry
|
||||||
|
operationId: updatePunishmentReason
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/HistoryType'
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
- $ref: '#/components/parameters/NewPunishmentReason'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Updated punishment
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PunishmentHistory'
|
||||||
|
default:
|
||||||
|
description: Unexpected error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
updatePunishmentUntil:
|
||||||
|
patch:
|
||||||
|
tags:
|
||||||
|
- history
|
||||||
|
summary: Update punishment expiry time
|
||||||
|
description: Updates the expiry time (until) for a specific punishment history entry (only for ban and mute)
|
||||||
|
operationId: updatePunishmentUntil
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/HistoryType'
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
- $ref: '#/components/parameters/NewUntil'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Updated punishment
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PunishmentHistory'
|
||||||
|
default:
|
||||||
|
description: Unexpected error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
removePunishment:
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- history
|
||||||
|
summary: Remove punishment
|
||||||
|
description: Removes a punishment from history. For bans and mutes, the punishment is deactivated; for kicks and warnings, the row is deleted.
|
||||||
|
operationId: removePunishment
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/HistoryType'
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Punishment removed
|
||||||
|
default:
|
||||||
|
description: Unexpected error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
HistoryType:
|
HistoryType:
|
||||||
@@ -250,6 +317,21 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: The id of the punishment that should be retrieved
|
description: The id of the punishment that should be retrieved
|
||||||
|
NewPunishmentReason:
|
||||||
|
name: reason
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: The new reason to set for the punishment
|
||||||
|
NewUntil:
|
||||||
|
name: until
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: The new expiry time (epoch millis)
|
||||||
schemas:
|
schemas:
|
||||||
SearchResults:
|
SearchResults:
|
||||||
type: integer
|
type: integer
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ UpdateMail:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/AppealResponse'
|
$ref: '../../generic/components.yml#/components/schemas/FormResponse'
|
||||||
default:
|
default:
|
||||||
description: Unexpected error
|
description: Unexpected error
|
||||||
content:
|
content:
|
||||||
@@ -49,7 +49,7 @@ MinecraftAppeal:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/AppealResponse'
|
$ref: '../../generic/components.yml#/components/schemas/FormResponse'
|
||||||
default:
|
default:
|
||||||
description: Unexpected error
|
description: Unexpected error
|
||||||
content:
|
content:
|
||||||
@@ -75,7 +75,7 @@ DiscordAppeal:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/AppealResponse'
|
$ref: '../../generic/components.yml#/components/schemas/FormResponse'
|
||||||
default:
|
default:
|
||||||
description: Unexpected error
|
description: Unexpected error
|
||||||
content:
|
content:
|
||||||
@@ -136,22 +136,6 @@ components:
|
|||||||
appeal:
|
appeal:
|
||||||
type: string
|
type: string
|
||||||
description: Appeal text explaining why the punishment should be reconsidered
|
description: Appeal text explaining why the punishment should be reconsidered
|
||||||
AppealResponse:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- id
|
|
||||||
- message
|
|
||||||
- verified_mail
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
description: Unique identifier for the submitted appeal for referring to it later
|
|
||||||
message:
|
|
||||||
type: string
|
|
||||||
description: Confirmation message
|
|
||||||
verified_mail:
|
|
||||||
type: boolean
|
|
||||||
description: If this user has verified their mail already
|
|
||||||
UpdateMail:
|
UpdateMail:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
StaffApply:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- applications
|
||||||
|
summary: Submit a Staff appeal
|
||||||
|
description: Submit an staff application
|
||||||
|
operationId: submitStaffApplication
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/StaffApplication'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Application created please verify email
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../../generic/components.yml#/components/schemas/FormResponse'
|
||||||
|
default:
|
||||||
|
description: Unexpected error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
StaffApplicationsIsOpen:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- applications
|
||||||
|
summary: Get if staff applications are open
|
||||||
|
description: Get if staff applications are open
|
||||||
|
operationId: getStaffApplicationsIsOpen
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: If staff applications are open
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
'403':
|
||||||
|
description: If staff applications are not open
|
||||||
|
default:
|
||||||
|
description: Unexpected error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
StaffApplication:
|
||||||
|
type: object
|
||||||
|
description: Schema for staff application
|
||||||
|
required:
|
||||||
|
- email
|
||||||
|
- age
|
||||||
|
- discordUsername
|
||||||
|
- meetsRequirements
|
||||||
|
- pronouns
|
||||||
|
- joinDate
|
||||||
|
- weeklyPlaytime
|
||||||
|
- availableDays
|
||||||
|
- availableTimes
|
||||||
|
- previousExperience
|
||||||
|
- pluginExperience
|
||||||
|
- moderatorExpectations
|
||||||
|
- additionalInfo
|
||||||
|
properties:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
maxLength: 320
|
||||||
|
description: Email address of the applicant
|
||||||
|
age:
|
||||||
|
type: integer
|
||||||
|
minimum: 13
|
||||||
|
description: Age of the applicant, must be 13 or older
|
||||||
|
discordUsername:
|
||||||
|
type: string
|
||||||
|
maxLength: 32
|
||||||
|
description: Discord username of the applicant
|
||||||
|
meetsRequirements:
|
||||||
|
type: boolean
|
||||||
|
description: Confirmation that the applicant meets all requirements
|
||||||
|
pronouns:
|
||||||
|
type: string
|
||||||
|
maxLength: 32
|
||||||
|
description: Preferred pronouns of the applicant
|
||||||
|
joinDate:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Date when the applicant joined the service
|
||||||
|
weeklyPlaytime:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
description: Average weekly playtime in hours
|
||||||
|
availableDays:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
maxLength: 256
|
||||||
|
description: Days of the week when the applicant is available
|
||||||
|
availableTimes:
|
||||||
|
type: string
|
||||||
|
maxLength: 1000
|
||||||
|
description: Time ranges when the applicant is available
|
||||||
|
previousExperience:
|
||||||
|
type: string
|
||||||
|
minLength: 10
|
||||||
|
maxLength: 4000
|
||||||
|
description: Description of previous relevant experience
|
||||||
|
pluginExperience:
|
||||||
|
type: string
|
||||||
|
minLength: 10
|
||||||
|
maxLength: 4000
|
||||||
|
description: Description of experience with plugins
|
||||||
|
moderatorExpectations:
|
||||||
|
type: string
|
||||||
|
minLength: 10
|
||||||
|
maxLength: 4000
|
||||||
|
description: Applicant's expectations and understanding of moderator responsibilities
|
||||||
|
additionalInfo:
|
||||||
|
type: string
|
||||||
|
maxLength: 4000
|
||||||
|
description: Any additional information the applicant wishes to provide
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
FormResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- message
|
||||||
|
- verified_mail
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: Unique identifier for the submitted form for referring to it later
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: Confirmation message
|
||||||
|
verified_mail:
|
||||||
|
type: boolean
|
||||||
|
description: If this user has verified their mail already
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
VoteStats:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- site
|
||||||
|
summary: Get vote stats
|
||||||
|
description: Get vote stats for current user
|
||||||
|
operationId: getVoteStats
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Vote stats retrieved
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/VoteData'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
VoteData:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- allVoteInfo
|
||||||
|
- voteStats
|
||||||
|
- voteStreak
|
||||||
|
- bestVoteStreak
|
||||||
|
properties:
|
||||||
|
allVoteInfo:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/VoteInfo'
|
||||||
|
voteStats:
|
||||||
|
$ref: '#/components/schemas/VoteStats'
|
||||||
|
voteStreak:
|
||||||
|
$ref: '#/components/schemas/VoteStreak'
|
||||||
|
bestVoteStreak:
|
||||||
|
$ref: '#/components/schemas/VoteStreak'
|
||||||
|
VoteInfo:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- siteName
|
||||||
|
- lastVoteTimestamp
|
||||||
|
properties:
|
||||||
|
siteName:
|
||||||
|
type: string
|
||||||
|
lastVoteTimestamp:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
VoteStats:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- total
|
||||||
|
- monthly
|
||||||
|
- weekly
|
||||||
|
- daily
|
||||||
|
properties:
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
monthly:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
weekly:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
daily:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
VoteStreak:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- dailyStreak
|
||||||
|
- weeklyStreak
|
||||||
|
- monthlyStreak
|
||||||
|
properties:
|
||||||
|
dailyStreak:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
weeklyStreak:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
monthlyStreak:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
Reference in New Issue
Block a user