Author SHA1 Message Date
stijn 8bfcdb6ccc Replace edit button with a Material icon, adjust styles, and add MatIconModule to component imports. 2025-10-24 21:28:20 +02:00
stijn 64ea68ab39 Refactor AuthenticatedUuid to singleton service and replace static calls across the codebase. Add JWT authority converters, improve punishment expiry handling, and enhance frontend dialog functionality for editing punishments. Extend CORS allowed methods and origins. 2025-10-24 21:10:34 +02:00
stijn f117cb2477 Remove debug console.log statements from auth.service.ts. 2025-10-24 19:52:26 +02:00
stijn d84d0c7fef Add conditional button styling and logic to indicate vote availability based on last vote timestamp. 2025-10-24 19:50:48 +02:00
stijn 00bf7caec2 Add vote statistics feature and improve vote page functionality 2025-10-24 19:39:08 +02:00
stijn 41dab473b0 Add admin endpoints for editing and removing punishments and implement frontend dialog for punishment management 2025-10-23 23:52:52 +02:00
stijn b71ea7da8b Limit returned usernames to 1 2025-10-21 22:48:21 +02:00
stijn a55806e5dd make scheduled methoded protected, fix connection 2025-10-21 22:22:43 +02:00
stijn 7e25cc583c Enable scheduling so code cache gets cleared 2025-10-21 22:17:08 +02:00
stijn 894dfac0c6 Validate weekly playtime as a whole number in staff application form. 2025-10-19 02:39:56 +02:00
stijn 300d33da7d Prevent duplicate staff application submissions by disabling the submit button during processing. 2025-10-18 23:10:56 +02:00
stijn 6f6801c728 Include applicant's username in staff application emails and Discord notifications. 2025-10-18 23:07:03 +02:00
stijn f8157e997a Update staff application close date to 2025-10-26 to match open period 2025-10-18 02:53:32 +02:00
stijn 74e8697fef Enhance "Staff applications closed" message with improved styling and structure. 2025-10-18 02:49:51 +02:00
stijn 8ad87da47e Update player count text to include context on home page 2025-10-18 02:46:31 +02:00
stijn 29a28e712e Add player count display with periodic updates to home page 2025-10-18 02:43:23 +02:00
stijn 6ad3b5221a Update Grove map button label to "Bayou" 2025-10-18 02:31:27 +02:00
stijn 1b697fcaa3 Add redirections for appeal and staff application forms paths 2025-10-17 22:05:00 +02:00
stijn ed9d41cdc6 Add conditional display logic to staff application form based on open/close status 2025-10-17 22:00:23 +02:00
stijn 5eaeb3552a Add API endpoint to check staff application availability and enforce open/close periods 2025-10-17 21:42:32 +02:00
stijn 8e9e267fb0 Update grove map button link to point bayou map 2025-10-17 20:11:23 +02:00
stijn 6d8f73201f Restrict "Particles" dropdown link visibility based on HEAD_MOD permission claim. 2025-10-12 22:05:20 +02:00
stijn 91e5a2a9a0 Refactor form validation and add user feedback with snackbar notifications in staff application form. 2025-10-12 21:57:40 +02:00
stijn 0005b3b6d4 Set login dialog width to 400px in auth guard. 2025-10-12 21:42:58 +02:00
stijn b3999b3389 Prompt login dialog when user is unauthenticated during auth guard check. 2025-10-12 21:40:54 +02:00
stijn 5a4df2572d Revert "Prompt login dialog when no JWT is found during authentication check."
This reverts commit e3fd0944df.
2025-10-12 21:37:54 +02:00
stijn e697f7ca90 Revert "Add navigation to current URL after login dialog completion"
This reverts commit 3da46c203b.
2025-10-12 21:37:53 +02:00
stijn 3da46c203b Add navigation to current URL after login dialog completion 2025-10-12 21:34:59 +02:00
stijn e3fd0944df Prompt login dialog when no JWT is found during authentication check. 2025-10-12 21:33:51 +02:00
stijn cd34cd93ad Update checkbox styles to use theme font color. 2025-10-12 21:29:04 +02:00
stijn a9294d1115 Update staff application email subject formatting 2025-10-12 21:24:46 +02:00
stijn 745dab4d80 Update query in SettingsMapper to use internal_name instead of name for database identification. 2025-10-12 21:16:16 +02:00
stijn dc65b19a8f Add error handling and logging improvements for database settings loading process. 2025-10-12 21:04:59 +02:00
stijn 01dab905d4 Improve staff application flow with error handling updates, new email address, and code formatting adjustments. 2025-09-27 21:13:04 +02:00
stijn 311d77fcb2 Enhance staff application flow with email verification checks, refined error handling, and improved user feedback in frontend and backend. 2025-09-27 20:00:44 +02:00
stijn cdbf862ecf Add staff application email and Discord notification integration 2025-09-24 23:33:36 +02:00
stijn 643b15f2e0 Add staff application support with database integration and submission flow 2025-09-24 23:12:09 +02:00
stijn f886609a0e Add staff application feature with API integration and frontend form implementation 2025-09-24 22:26:17 +02:00
stijn 2a0f38aa28 Configure proxy for API requests and remove hardcoded apiUrl from environment files. 2025-09-24 21:47:55 +02:00
stijn 4878ad9f0d Configure proxy for API requests and remove hardcoded apiUrl from environment files. 2025-09-24 21:44:41 +02:00
Peter 80cb2d0ad1 Modified styling for appeal template 2025-08-30 15:41:26 +02:00
Peter 53f67c0b67 Updated version and voting requirements 2025-08-30 14:16:07 +02:00
stijn 1f1f1793e3 Remove redundant comment in AppealDiscord and enhance state handling in SentComponent. 2025-08-24 03:15:42 +02:00
stijn 4962d16abd Add EmailVerificationMapper initialization in web database setup. 2025-08-24 02:49:09 +02:00
stijn cad574b8fb Set default email value programmatically in appeal form and remove inline default logic. 2025-08-24 02:43:49 +02:00
stijn c75f0cdb15 Mark appeals as sent when successfully submitted and processed, ensuring accurate tracking and status updates. 2025-08-24 02:39:47 +02:00
stijn fe545972e3 Fix typo in email validation message for improved clarity in appeal form UI. 2025-08-24 00:49:51 +02:00
stijn eab1c9322b Add embed message support to Discord bot and update appeal flow to use embeds for Discord notifications 2025-08-24 00:43:58 +02:00
stijn ffddffa8dc Add Discord bot support for sending appeals to specified channels and integrate with appeal flow 2025-08-23 23:51:45 +02:00
stijn 0b4c1ccebf Format createdAt in appeal emails to UTC timezone and improve readability. 2025-08-23 23:27:45 +02:00
stijn 2e89fcec66 Add initial Discord bot integration with JDA setup and environment token configuration 2025-08-23 23:17:51 +02:00
stijn 42b11eecf1 Add email re-validation handling with UI feedback and backend validation to prevent duplicate email verification attempts 2025-08-23 22:59:22 +02:00
stijn d1da1296bb Add SentComponent for form submission confirmation and integrate with email verification flow 2025-08-23 22:42:18 +02:00
stijn 523bf3d43f Refactor SentComponent to VerifyMailDialogComponent for improved clarity and usability in email verification flow. 2025-08-23 22:34:08 +02:00
stijn 4ccce7e190 Improve email verification flow by adding verified email pre-fill, validation handling, and dialog-based verification support. 2025-08-23 22:32:44 +02:00
stijn 641083732d Add email verification functionality, including backend support, email handling, and user interface integration. 2025-08-23 21:46:10 +02:00
stijn da17cf9696 Remove defaultAuthStatus from all environment configurations. 2025-08-23 20:13:59 +02:00
stijn de1876c90c Add historyType and historyId to Appeal, update database schema, API, and email templates to include punishment details. 2025-08-16 23:40:20 +02:00
stijn c3f3b20807 Update mail configuration to use SSL with custom socket factory. 2025-08-16 23:01:22 +02:00
stijn 37fb49cda1 Inject SpringTemplateEngine into AppealMail and remove redundant field initialization. 2025-08-16 20:28:29 +02:00
stijn db642103ed Add email notification service for appeals using Spring Mail and Thymeleaf templates. 2025-08-16 20:23:35 +02:00
stijn f026f24263 Refactor createAppeal in AppealMapper to void return type, adjust AppealController to remove redundant UUID handling. Optimize AppealDataMapper to use UUID.randomUUID(). 2025-08-14 02:34:28 +02:00
stijn eaee31ab2b Change createAppeal method in AppealMapper to return UUID, adjust AppealController to construct Appeal with generated UUID. 2025-08-14 01:37:57 +02:00
stijn 24d7cfe913 Register UUIDTypeHandler in MyBatis configuration to handle UUID mapping in Connection.java. 2025-08-13 23:56:31 +02:00
stijn 770a2e0d14 Add Minecraft appeal functionality with database integration, UUID handling, and API response adjustments. 2025-08-13 23:54:20 +02:00
stijn 101794d8f2 Remove username property from appeal.yml schema in Discord ban appeal form. 2025-08-08 21:01:58 +02:00
104 changed files with 5038 additions and 465 deletions
+7
View File
@@ -27,6 +27,7 @@ dependencies {
implementation(project(":open_api"))
implementation(project(":database"))
implementation(project(":frontend"))
implementation(project(":discord"))
annotationProcessor("org.projectlombok:lombok")
implementation("com.mysql:mysql-connector-j:8.0.32")
implementation("org.mybatis:mybatis:3.5.13")
@@ -36,6 +37,12 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.security:spring-security-oauth2-resource-server")
implementation("org.springframework.security:spring-security-oauth2-jose")
implementation("org.springframework.boot:spring-boot-starter-mail:3.1.5")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
//Open API
implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
implementation("io.swagger.core.v3:swagger-models:2.2.37")
//AOP
implementation("org.aspectj:aspectjrt:1.9.19")
@@ -5,7 +5,7 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@SpringBootApplication
@SpringBootApplication(scanBasePackages = {"com.alttd.altitudeweb"})
@EnableAspectJAutoProxy
public class AltitudeWebApplication {
@@ -9,23 +9,31 @@ import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import java.security.KeyPair;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@@ -39,18 +47,21 @@ public class SecurityConfig {
return http
.authorizeHttpRequests(
auth -> auth
.requestMatchers("/api/form/**").hasAuthority(PermissionClaimDto.USER.getValue())
.requestMatchers("/api/login/userLogin").hasAuthority(PermissionClaimDto.USER.getValue())
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/form/**").authenticated()
.requestMatchers("/api/login/getUsername").authenticated()
.requestMatchers("/api/mail/**").authenticated()
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/login/userLogin/**").permitAll()
.anyRequest().permitAll()
)
.csrf(AbstractHttpConfigurer::disable)
.anonymous(AbstractHttpConfigurer::disable)
.oauth2ResourceServer(
oauth2 -> oauth2
.jwt(Customizer.withDefaults())
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
.authenticationEntryPoint(securityAuthFailureHandler)
.accessDeniedHandler(securityAuthFailureHandler)
)
@@ -81,4 +92,46 @@ public class SecurityConfig {
KeyPair keyPair = keyPairService.getJwtSigningKeyPair();
return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> claims = jwt.getClaims();
Object authoritiesClaim = claims.get("authorities");
if (authoritiesClaim instanceof List<?> authorities) {
Collection<GrantedAuthority> authorityList = authorities.stream()
.map(Object::toString)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
log.debug("Authorities found in authorities: {}", authorityList);
return authorityList;
}
Object scopeClaim = claims.get("scope");
if (scopeClaim instanceof String scopeString) {
Collection<GrantedAuthority> authorityList = Arrays.stream(scopeString.split(" "))
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
.collect(Collectors.toList());
log.debug("Authorities found in authorities scope string: {}", authorityList);
return authorityList;
}
if (scopeClaim instanceof List<?> scopeList) {
Collection<GrantedAuthority> authorityList = scopeList.stream()
.map(Object::toString)
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
.collect(Collectors.toList());
log.debug("Authorities found in authorities scope list: {}", authorityList);
return authorityList;
}
log.debug("No granted authorities found");
return Collections.emptyList();
});
return converter;
}
}
@@ -18,7 +18,7 @@ public class CorsConfig implements WebMvcConfigurer {
log.info("Registering CORS mappings for {}", String.join(", ", allowedOrigins));
registry.addMapping("/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true);
}
@@ -0,0 +1,42 @@
package com.alttd.altitudeweb.controllers.data_from_auth;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.UUID;
@Service
public class AuthenticatedUuid {
@Value("${UNSECURED:#{false}}")
private boolean unsecured;
/**
* Extracts and validates the authenticated user's UUID from the JWT token.
*
* @return The UUID of the authenticated user
* @throws ResponseStatusException with 401 status if authentication is invalid
*/
public UUID getAuthenticatedUserUuid() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
if (unsecured) {
return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f");
}
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
}
String stringUuid = jwt.getSubject();
try {
return UUID.fromString(stringUuid);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid UUID format");
}
}
}
@@ -1,36 +1,138 @@
package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.AppealsApi;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.model.AppealResponseDto;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.litebans.HistoryType;
import com.alttd.altitudeweb.database.litebans.IdHistoryMapper;
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.mappers.AppealDataMapper;
import com.alttd.altitudeweb.model.DiscordAppealDto;
import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.model.MinecraftAppealDto;
import com.alttd.altitudeweb.model.UpdateMailDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.mail.AppealMail;
import com.alttd.altitudeweb.setup.Connection;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@AllArgsConstructor
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class AppealController implements AppealsApi {
private final AppealDataMapper mapper;
private final AppealMail appealMail;
private final com.alttd.altitudeweb.services.discord.AppealDiscord appealDiscord;
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
@Override
public ResponseEntity<MinecraftAppealDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
}
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
@Override
public ResponseEntity<AppealResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Minecraft appeals are not yet supported");
public ResponseEntity<FormResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
boolean success = true;
CompletableFuture<Appeal> appealCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Loading history by id");
try {
Appeal appeal = mapper.minecraftAppealDtoToAppeal(minecraftAppealDto);
sqlSession.getMapper(AppealMapper.class).createAppeal(appeal);
appealCompletableFuture.complete(appeal);
} catch (Exception e) {
log.error("Failed to load history count", e);
appealCompletableFuture.completeExceptionally(e);
}
});
Appeal appeal = appealCompletableFuture.join();
HistoryRecord history = getHistory(appeal.historyType(), appeal.historyId());
if (history == null) {
throw new ResponseStatusException(HttpStatusCode.valueOf(404), "History not found");
}
// Send to Discord channels
try {
appealDiscord.sendAppealToDiscord(appeal, history);
} catch (Exception e) {
log.error("Failed to send appeal {} to Discord", appeal.id(), e);
success = false;
}
appealMail.sendAppealNotification(appeal, history);
CompletableFuture<Optional<EmailVerification>> emailVerificationCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Retrieving mail by uuid and address");
EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class)
.findByUserAndEmail(appeal.uuid(), appeal.email().toLowerCase());
emailVerificationCompletableFuture.complete(Optional.ofNullable(verifiedMail));
});
Optional<EmailVerification> optionalEmailVerification = emailVerificationCompletableFuture.join();
if (optionalEmailVerification.isEmpty()) {
return ResponseEntity.badRequest().build();
}
EmailVerification emailVerification = optionalEmailVerification.get();
if (!emailVerification.verified()) {
return ResponseEntity.badRequest().build();
}
if (!success) {
return ResponseEntity.internalServerError().build();
}
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Marking appeal {} as sent", appeal.id());
sqlSession.getMapper(AppealMapper.class)
.markAppealAsSent(appeal.id());
});
FormResponseDto appealResponseDto = new FormResponseDto(
appeal.id().toString(),
"Your appeal has been submitted. You will be notified when it has been reviewed.",
true);
return ResponseEntity.ok().body(appealResponseDto);
}
@Override
public ResponseEntity<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");
}
private HistoryRecord getHistory(String type, int id) {
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<HistoryRecord> historyRecordCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS)
.runQuery(sqlSession -> {
log.debug("Loading history by id");
try {
HistoryRecord punishment = sqlSession.getMapper(IdHistoryMapper.class)
.getRecentHistory(historyTypeEnum, id);
historyRecordCompletableFuture.complete(punishment);
} catch (Exception e) {
log.error("Failed to load history count", e);
historyRecordCompletableFuture.completeExceptionally(e);
}
});
return historyRecordCompletableFuture.join();
}
}
@@ -0,0 +1,155 @@
package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.ApplicationsApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.mappers.StaffApplicationDataMapper;
import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.model.StaffApplicationDto;
import com.alttd.altitudeweb.services.discord.StaffApplicationDiscord;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.mail.StaffApplicationMail;
import com.alttd.altitudeweb.setup.Connection;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@AllArgsConstructor
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class ApplicationController implements ApplicationsApi {
private final AuthenticatedUuid authenticatedUuid;
private final StaffApplicationDataMapper staffApplicationDataMapper;
private final StaffApplicationMail staffApplicationMail;
private final StaffApplicationDiscord staffApplicationDiscord;
private final Instant open = Instant.parse("2025-10-18T00:00:00Z");
private final Instant close = Instant.parse("2025-10-26T00:00:00Z");
@Override
public ResponseEntity<Boolean> getStaffApplicationsIsOpen() {
return ResponseEntity.ok(isOpen());
}
private boolean isOpen() {
Instant now = Instant.now();
return !now.isBefore(open) && !now.isAfter(close);
}
@Override
public ResponseEntity<FormResponseDto> submitStaffApplication(StaffApplicationDto staffApplicationDto) {
if (!isOpen()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
UUID userUuid = authenticatedUuid.getAuthenticatedUserUuid();
String email = staffApplicationDto.getEmail() == null ? null : staffApplicationDto.getEmail().toLowerCase();
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, email);
if (optionalEmail.isEmpty() || !optionalEmail.get().verified()) {
log.warn("User {} attempted to submit an application without a verified email {}", userUuid, email);
return ResponseEntity.badRequest().build();
}
// Map and persist application
StaffApplication application = staffApplicationDataMapper.map(userUuid, staffApplicationDto);
saveApplication(application);
String username = getUsername(userUuid);
try {
if (!staffApplicationMail.sendApplicationEmail(username, application)) {
log.warn("Failed to send staff application email for {}", application.id());
return ResponseEntity.internalServerError().build();
}
} catch (Exception e) {
log.error("Error while sending staff application email for {}", application.id(), e);
return ResponseEntity.internalServerError().build();
}
try {
staffApplicationDiscord.sendApplicationToDiscord(username, application);
} catch (Exception e) {
log.error("Failed to send staff application {} to Discord", application.id(), e);
return ResponseEntity.internalServerError().build();
}
try {
markAsSent(application.id());
} catch (Exception e) {
log.error("Failed to mark application {} as sent", application.id(), e);
return ResponseEntity.internalServerError().build();
}
FormResponseDto response = buildResponse(application);
return ResponseEntity.status(200).body(response);
}
private void saveApplication(StaffApplication application) {
CompletableFuture<Void> saveFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
try {
sqlSession.getMapper(StaffApplicationMapper.class).insert(application);
saveFuture.complete(null);
} catch (Exception e) {
log.error("Failed to insert staff application", e);
saveFuture.completeExceptionally(e);
}
});
saveFuture.join();
}
private Optional<EmailVerification> fetchEmailVerification(UUID userUuid, String email) {
CompletableFuture<Optional<EmailVerification>> emailVerificationFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class)
.findByUserAndEmail(userUuid, email);
emailVerificationFuture.complete(Optional.ofNullable(verifiedMail));
});
return emailVerificationFuture.join();
}
private void markAsSent(UUID applicationId) {
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> sqlSession.getMapper(StaffApplicationMapper.class).markAsSent(applicationId));
}
private FormResponseDto buildResponse(StaffApplication application) {
String message = "Your staff application has been submitted. You will be notified when it has been reviewed.";
return new FormResponseDto(
application.id().toString(),
message,
true
);
}
private String getUsername(UUID uuid) {
CompletableFuture<String> usernameFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LUCK_PERMS)
.runQuery(sqlSession -> {
log.debug("Loading username for uuid {}", uuid);
try {
String username = sqlSession.getMapper(UUIDUsernameMapper.class).getUsernameFromUUID(uuid.toString());
usernameFuture.complete(username);
} catch (Exception e) {
log.error("Failed to load username for uuid {}", uuid, e);
usernameFuture.completeExceptionally(e);
}
});
return usernameFuture.join();
}
}
@@ -0,0 +1,104 @@
package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.MailApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.model.MailResponseDto;
import com.alttd.altitudeweb.model.SubmitEmailDto;
import com.alttd.altitudeweb.model.VerifyCodeDto;
import com.alttd.altitudeweb.model.EmailEntryDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.mail.MailVerificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RequiredArgsConstructor
@RateLimit(limit = 60, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class MailController implements MailApi {
private final MailVerificationService mailVerificationService;
private final AuthenticatedUuid authenticatedUuid;
@Override
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit")
public ResponseEntity<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream()
.filter(EmailVerification::verified)
.map(EmailVerification::email)
.anyMatch(mail -> mail.equalsIgnoreCase(submitEmailDto.getEmail()));
if (emailAlreadyVerified) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email already verified for user");
}
EmailVerification saved = mailVerificationService.submitEmail(uuid, submitEmailDto.getEmail());
MailResponseDto response = new MailResponseDto()
.email(saved.email())
.message("Verification email sent")
.verified(false);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@Override
@RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify")
public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode());
if (optionalEmailVerification.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code");
}
EmailVerification emailVerification = optionalEmailVerification.get();
MailResponseDto response = new MailResponseDto()
.email(emailVerification.email())
.message("Email verified successfully")
.verified(true);
return ResponseEntity.ok(response);
}
@Override
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend")
public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
EmailVerification updated = mailVerificationService.resend(uuid, submitEmailDto.getEmail());
if (updated == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
}
MailResponseDto response = new MailResponseDto()
.email(updated.email())
.message("Verification email resent")
.verified(false);
return ResponseEntity.ok(response);
}
@Override
@RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete")
public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail());
if (!deleted) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
}
return ResponseEntity.noContent().build();
}
@Override
public ResponseEntity<List<EmailEntryDto>> getUserEmails() {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
List<EmailVerification> emails = mailVerificationService.listAll(uuid);
List<EmailEntryDto> result = emails.stream()
.map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified()))
.toList();
return ResponseEntity.ok(result);
}
}
@@ -1,6 +1,7 @@
package com.alttd.altitudeweb.controllers.history;
import com.alttd.altitudeweb.api.HistoryApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.model.HistoryCountDto;
import com.alttd.altitudeweb.model.PunishmentHistoryListDto;
@@ -8,9 +9,11 @@ import com.alttd.altitudeweb.setup.Connection;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.litebans.*;
import com.alttd.altitudeweb.model.PunishmentHistoryDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@@ -20,8 +23,11 @@ import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RateLimit(limit = 30, timeValue = 10, timeUnit = TimeUnit.SECONDS)
@RequiredArgsConstructor
public class HistoryApiController implements HistoryApi {
private final AuthenticatedUuid authenticatedUuid;
@Override
public ResponseEntity<PunishmentHistoryListDto> getHistoryForAll(String userType, String type, Integer page) {
return getHistoryForUsers(userType, type, "", page);
@@ -229,4 +235,108 @@ public class HistoryApiController implements HistoryApi {
.type(type)
.id(historyRecord.getId());
}
@Override
public ResponseEntity<PunishmentHistoryDto> updatePunishmentReason(String type, Integer id, String reason) {
log.debug("Updating reason for {} id {} to {}", type, id, reason);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
try {
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
if (before == null) {
result.complete(null);
return;
}
int changed = editMapper.setReason(historyTypeEnum, id, reason);
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
result.complete(after != null ? mapPunishmentHistory(after) : null);
} catch (Exception e) {
log.error("Failed to update reason for {} id {}", type, id, e);
result.completeExceptionally(e);
}
});
PunishmentHistoryDto body = result.join();
if (body == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(body);
}
@Override
public ResponseEntity<PunishmentHistoryDto> updatePunishmentUntil(String type, Integer id, Long until) {
log.debug("Updating until for {} id {} to {}", type, id, until);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
try {
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
if (before == null) {
result.complete(null);
return;
}
int changed = editMapper.setUntil(historyTypeEnum, id, until);
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
result.complete(after != null ? mapPunishmentHistory(after) : null);
} catch (IllegalArgumentException e) {
log.warn("Invalid until edit for type {} id {}: {}", type, id, e.getMessage());
result.complete(null);
} catch (Exception e) {
log.error("Failed to update until for {} id {}", type, id, e);
result.completeExceptionally(e);
}
});
PunishmentHistoryDto body = result.join();
if (body == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(body);
}
@Override
public ResponseEntity<Void> removePunishment(String type, Integer id) {
log.debug("Removing punishment for {} id {}", type, id);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<Boolean> result = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
try {
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
if (before == null) {
result.complete(false);
return;
}
UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
int changed = editMapper.remove(historyTypeEnum, id);
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",
actorName, actorUuid, historyTypeEnum, id,
before.getRemovedByName() == null ? 1 : 0, before.getRemovedByName(), before.getRemovedByReason(),
changed);
result.complete(changed > 0);
} catch (Exception e) {
log.error("Failed to remove punishment for {} id {}", type, id, e);
result.completeExceptionally(e);
}
});
Boolean ok = result.join();
if (ok == null || !ok) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.noContent().build();
}
}
@@ -1,26 +1,22 @@
package com.alttd.altitudeweb.controllers.login;
import com.alttd.altitudeweb.api.LoginApi;
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.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.litebans.RecentNamesMapper;
import com.alttd.altitudeweb.database.web_db.PrivilegedUser;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.model.UsernameDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import com.alttd.altitudeweb.services.limits.RateLimit;
import org.springframework.security.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.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
@@ -36,10 +32,12 @@ import java.util.concurrent.TimeUnit;
@Slf4j
@RequiredArgsConstructor
@EnableScheduling
@RestController
public class LoginController implements LoginApi {
private final JwtEncoder jwtEncoder;
private final AuthenticatedUuid authenticatedUuid;
@Value("${login.secret:#{null}}")
private String loginSecret;
@@ -47,12 +45,13 @@ public class LoginController implements LoginApi {
@Value("${my-server.address:#{null}}")
private String serverAddress;
private record CacheEntry(UUID uuid, Instant expiry) {}
private record CacheEntry(UUID uuid, Instant expiry) {
}
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
@Scheduled(fixedRate = 300000) // 5 minutes in milliseconds
private void clearExpiredCacheEntries() {
protected void clearExpiredCacheEntries() {
Instant now = Instant.now();
int initialCacheSize = cache.size();
cache.entrySet().removeIf(entry -> entry.getValue().expiry().isBefore(now));
@@ -69,6 +68,8 @@ public class LoginController implements LoginApi {
return ResponseEntity.badRequest().build();
}
log.info("{} is requesting a login code", uuid);
if (authorization == null || !authorization.startsWith("SECRET ")) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
@@ -84,35 +85,35 @@ public class LoginController implements LoginApi {
.findFirst();
if (key.isPresent()) {
log.info("{} got cached key: {}", uuid, key.get());
return ResponseEntity.ok(key.get());
}
String loginCode = generateLoginCode(uuidFromString);
log.info("{} received login code: {}", uuid, loginCode);
return ResponseEntity.ok(loginCode);
}
@Override
public ResponseEntity<UsernameDto> getUsername() {
log.debug("Loading username for logged in user");
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
log.debug("Loaded authentication for logged in user {}", authentication);
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
log.debug("Loaded authentication for logged in user is null or not a jwt");
return ResponseEntity.status(401).build();
}
String stringUuid = jwt.getSubject();
UUID uuid;
try {
uuid = UUID.fromString(stringUuid);
log.debug("Loaded username for logged in user {}", uuid);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
try {
// Get authenticated UUID using the utility method
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
log.debug("Loaded username for logged in user {}", uuid);
// Create response with username
UsernameDto usernameDto = new UsernameDto();
usernameDto.setUsername(getUsername(uuid));
log.debug("Loaded username for logged in user {}", usernameDto.getUsername());
return ResponseEntity.ok(usernameDto);
} catch (ResponseStatusException e) {
// The utility method already throws proper exceptions, we just need to convert them to ResponseEntity
return ResponseEntity.status(e.getStatusCode()).build();
}
}
private String getUsername(UUID uuid) {
@@ -134,20 +135,32 @@ public class LoginController implements LoginApi {
return username.join();
}
@Value("${UNSECURED:#{false}}")
private boolean unsecured;
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
@Override
public ResponseEntity<String> login(String code) {
if (unsecured) {
log.warn("Unsecured login is enabled, skipping login validation!");
} else {
log.info("Received login request with code {}", code);
}
if (code == null) {
log.warn("Received null login code");
return ResponseEntity.badRequest().build();
}
CacheEntry cacheEntry = cache.get(code);
if (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now())) {
if (!unsecured && (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now()))) {
log.warn("Received invalid login code {}", code);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (unsecured && cacheEntry == null) {
cacheEntry = new CacheEntry(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"), Instant.now().plusSeconds(TimeUnit.DAYS.toSeconds(1)));
}
String token = generateToken(cacheEntry.uuid);
log.debug("Generated token for user {} with token {}", cacheEntry.uuid, token);
@@ -196,10 +209,17 @@ public class LoginController implements LoginApi {
.runQuery(sqlSession -> {
try {
log.debug("Loading user by uuid {}", uuid.toString());
Optional<PrivilegedUser> privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class)
.getUserByUuid(uuid.toString());
PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class);
Optional<PrivilegedUser> privilegedUser = mapper
.getUserByUuid(uuid);
if (privilegedUser.isEmpty()) {
int privilegedUserId = mapper.createPrivilegedUser(uuid);
privilegedUserCompletableFuture.complete(
Optional.of(new PrivilegedUser(privilegedUserId, uuid, List.of())));
} else {
privilegedUserCompletableFuture.complete(privilegedUser);
}
} catch (Exception e) {
log.error("Failed to load user by uuid", e);
privilegedUserCompletableFuture.completeExceptionally(e);
@@ -219,7 +239,8 @@ public class LoginController implements LoginApi {
claimList.stream().map(PermissionClaimDto::getValue).toList());
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(serverAddress)
.claim("authorities", claimList.stream().map(PermissionClaimDto::getValue).toList())
.claim("authorities",
claimList.stream().map(PermissionClaimDto::getValue).toList())
.issuedAt(now)
.expiresAt(expiryTime)
.subject(uuid.toString())
@@ -0,0 +1,37 @@
package com.alttd.altitudeweb.controllers.site;
import com.alttd.altitudeweb.api.SiteApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.model.VoteDataDto;
import com.alttd.altitudeweb.model.VoteStatsDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.site.VoteService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RequiredArgsConstructor
@RateLimit(limit = 2, timeValue = 10, timeUnit = TimeUnit.SECONDS)
public class SiteController implements SiteApi {
private final VoteService voteService;
private final AuthenticatedUuid authenticatedUuid;
@Override
public ResponseEntity<VoteDataDto> getVoteStats() {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid);
if (optionalVoteDataDto.isEmpty()) {
return ResponseEntity.noContent().build();
}
VoteDataDto voteDataDto = optionalVoteDataDto.get();
return ResponseEntity.ok(voteDataDto);
}
}
@@ -0,0 +1,35 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import com.alttd.altitudeweb.model.MinecraftAppealDto;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.UUID;
@Service
public class AppealDataMapper {
public MinecraftAppealDto appealToMinecraftAppealDto(Appeal appeal) {
MinecraftAppealDto minecraftAppealDto = new MinecraftAppealDto();
minecraftAppealDto.setAppeal(appeal.reason());
minecraftAppealDto.setUsername(appeal.username());
minecraftAppealDto.setUuid(appeal.uuid());
minecraftAppealDto.setEmail(appeal.email());
return minecraftAppealDto;
}
public Appeal minecraftAppealDtoToAppeal(MinecraftAppealDto minecraftAppealDto) {
return new Appeal(
UUID.randomUUID(),
minecraftAppealDto.getUuid(),
minecraftAppealDto.getPunishmentType().toString(),
minecraftAppealDto.getPunishmentId(),
minecraftAppealDto.getUsername(),
minecraftAppealDto.getAppeal(),
Instant.now(),
null,
minecraftAppealDto.getEmail(),
null
);
}
}
@@ -0,0 +1,53 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import com.alttd.altitudeweb.model.StaffApplicationDto;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class StaffApplicationDataMapper {
/**
* Maps the incoming DTO and the authenticated user's UUID to a StaffApplication entity.
* Normalizes and prepares fields as needed (lowercase email, join availableDays, timestamps, ids).
*/
public StaffApplication map(UUID userUuid, StaffApplicationDto dto) {
String email = dto.getEmail() == null ? null : dto.getEmail().toLowerCase();
String availableDaysJoined = joinList(dto.getAvailableDays());
return new StaffApplication(
UUID.randomUUID(),
userUuid,
email,
dto.getAge(),
dto.getDiscordUsername(),
Boolean.TRUE.equals(dto.getMeetsRequirements()),
dto.getPronouns(),
dto.getJoinDate(),
dto.getWeeklyPlaytime(),
availableDaysJoined,
dto.getAvailableTimes(),
dto.getPreviousExperience(),
dto.getPluginExperience(),
dto.getModeratorExpectations(),
dto.getAdditionalInfo(),
Instant.now(),
null,
null
);
}
private String joinList(List<String> list) {
if (list == null) return null;
// Avoid NPEs and trim entries
return list.stream()
.filter(s -> s != null && !s.isBlank())
.map(String::trim)
.collect(Collectors.joining(","));
}
}
@@ -0,0 +1,62 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow;
import com.alttd.altitudeweb.model.VoteDataDto;
import com.alttd.altitudeweb.model.VoteInfoDto;
import com.alttd.altitudeweb.model.VoteStatsDto;
import com.alttd.altitudeweb.model.VoteStreakDto;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
@Service
public class VotingStatsRowToVoteDataDto {
public VoteDataDto map(VotingStatsRow votingStatsRow) {
VoteDataDto voteDataDto = new VoteDataDto();
voteDataDto.setVoteStats(getVoteStats(votingStatsRow));
voteDataDto.setVoteStreak(getVoteStreak(votingStatsRow));
voteDataDto.setBestVoteStreak(getBestVoteStreak(votingStatsRow));
voteDataDto.setAllVoteInfo(getVoteInfo(votingStatsRow.lastVotes()));
return voteDataDto;
}
private VoteStreakDto getVoteStreak(VotingStatsRow votingStatsRow) {
VoteStreakDto voteStreakDto = new VoteStreakDto();
voteStreakDto.setDailyStreak(votingStatsRow.dailyStreak());
voteStreakDto.setWeeklyStreak(votingStatsRow.weeklyStreak());
voteStreakDto.setMonthlyStreak(votingStatsRow.monthlyStreak());
return voteStreakDto;
}
private VoteStreakDto getBestVoteStreak(VotingStatsRow votingStatsRow) {
VoteStreakDto voteStreakDto = new VoteStreakDto();
voteStreakDto.setDailyStreak(votingStatsRow.bestDailyStreak());
voteStreakDto.setWeeklyStreak(votingStatsRow.bestWeeklyStreak());
voteStreakDto.setMonthlyStreak(votingStatsRow.bestMonthlyStreak());
return voteStreakDto;
}
private VoteStatsDto getVoteStats(VotingStatsRow votingStatsRow) {
VoteStatsDto voteStatsDto = new VoteStatsDto();
voteStatsDto.setDaily(votingStatsRow.totalVotesToday());
voteStatsDto.setWeekly(votingStatsRow.totalVotesThisWeek());
voteStatsDto.setMonthly(votingStatsRow.totalVotesThisMonth());
voteStatsDto.setTotal(votingStatsRow.totalVotesAllTime());
return voteStatsDto;
}
private List<VoteInfoDto> getVoteInfo(String lastVotes) {
return Arrays.stream(lastVotes.split("%line%"))
.map(voteInfo -> {
String[] siteAndTimestamp = voteInfo.split("//");
VoteInfoDto voteInfoDto = new VoteInfoDto();
voteInfoDto.setSiteName(siteAndTimestamp[0]);
voteInfoDto.setLastVoteTimestamp(Long.parseLong(siteAndTimestamp[1]));
return voteInfoDto;
})
.toList();
}
}
@@ -0,0 +1,135 @@
package com.alttd.altitudeweb.services.discord;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.discord.OutputChannel;
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
import com.alttd.altitudeweb.database.litebans.HistoryCountMapper;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.litebans.HistoryType;
import com.alttd.altitudeweb.database.litebans.UserType;
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import com.alttd.altitudeweb.setup.Connection;
import com.alttd.webinterface.send_message.DiscordSender;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
public class AppealDiscord {
private static final String OUTPUT_TYPE = "APPEAL";
public void sendAppealToDiscord(Appeal appeal, HistoryRecord history) {
// Fetch channels
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
try {
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
.getChannelsWithOutputType(OUTPUT_TYPE);
channelsFuture.complete(channels);
} catch (Exception e) {
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
channelsFuture.complete(new ArrayList<>());
}
});
CompletableFuture<Integer> bansF = getCountAsync(HistoryType.BAN, appeal.uuid());
CompletableFuture<Integer> mutesF = getCountAsync(HistoryType.MUTE, appeal.uuid());
CompletableFuture<Integer> warnsF = getCountAsync(HistoryType.WARN, appeal.uuid());
CompletableFuture<Integer> kicksF = getCountAsync(HistoryType.KICK, appeal.uuid());
List<OutputChannel> channels = channelsFuture.join();
int bans = bansF.join();
int mutes = mutesF.join();
int warns = warnsF.join();
int kicks = kicksF.join();
if (channels.isEmpty()) {
log.warn("No Discord output channels found for type {}. Skipping Discord send.", OUTPUT_TYPE);
return;
}
// Build embed
boolean active = history.getUntil() == null || history.getUntil() <= 0 || history.getUntil() > System.currentTimeMillis();
String createdAt = formatInstant(appeal.createdAt());
List<DiscordSender.EmbedField> fields = new ArrayList<>();
// Group: User
fields.add(new DiscordSender.EmbedField(
"User",
"Username: " + safe(appeal.username()) + "\n" +
"UUID: " + safe(String.valueOf(appeal.uuid())) + "\n" +
"Email: " + safe(appeal.email()) + "\n" +
"Submitted: " + createdAt,
false
));
// Group: Punishment
fields.add(new DiscordSender.EmbedField(
"Punishment",
"Type: " + safe(String.valueOf(appeal.historyType())) + "\n" +
"ID: " + safe(String.valueOf(appeal.historyId())) + "\n" +
"Reason: " + safe(history.getReason()) + "\n" +
"Active: " + active,
false
));
// Group: Previous punishments
fields.add(new DiscordSender.EmbedField(
"Previous punishments",
"Bans: " + bans + "\n" +
"Mutes: " + mutes + "\n" +
"Warnings: " + warns + "\n" +
"Kicks: " + kicks,
true
));
String description = safe(appeal.reason());
List<Long> channelIds = channels.stream()
.map(OutputChannel::channel)
.toList();
// colorRgb = null (use default), timestamp = appeal.createdAt if available
Instant timestamp = appeal.createdAt() != null ? appeal.createdAt() : Instant.now();
DiscordSender.getInstance().sendEmbedToChannels(
channelIds,
"New Appeal Submitted",
description,
fields,
null,
timestamp,
null
);
}
private CompletableFuture<Integer> getCountAsync(HistoryType type, java.util.UUID uuid) {
CompletableFuture<Integer> future = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS).runQuery(sql -> {
try {
Integer count = sql.getMapper(HistoryCountMapper.class)
.getUuidPunishmentCount(type, UserType.PLAYER, uuid);
future.complete(count == null ? 0 : count);
} catch (Exception e) {
log.error("Failed to load punishment count for {} ({})", type, uuid, e);
future.complete(0);
}
});
return future;
}
private String safe(String s) {
return s == null ? "unknown" : s;
}
private String formatInstant(Instant instant) {
if (instant == null) return "unknown";
return instant.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
}
}
@@ -0,0 +1,103 @@
package com.alttd.altitudeweb.services.discord;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.discord.OutputChannel;
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import com.alttd.altitudeweb.setup.Connection;
import com.alttd.webinterface.send_message.DiscordSender;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
public class StaffApplicationDiscord {
private static final String OUTPUT_TYPE = "STAFF_APPLICATION";
public void sendApplicationToDiscord(String username, StaffApplication application) {
// Fetch channels for staff applications
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
try {
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
.getChannelsWithOutputType(OUTPUT_TYPE);
channelsFuture.complete(channels);
} catch (Exception e) {
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
channelsFuture.complete(new ArrayList<>());
}
});
List<OutputChannel> channels = channelsFuture.join();
if (channels.isEmpty()) {
log.warn("No Discord output channels found for type {}. Skipping Discord send.", OUTPUT_TYPE);
return;
}
// Build embed content
List<DiscordSender.EmbedField> fields = new ArrayList<>();
fields.add(new DiscordSender.EmbedField(
"Applicant",
"Username: " + safe(username) + "\n" +
"Discord: " + safe(application.discordUsername()) + "\n" +
"Email: " + safe(application.email()) + "\n" +
"Age: " + safe(String.valueOf(application.age())) + "\n" +
"Meets reqs: " + (application.meetsRequirements() != null && application.meetsRequirements()),
false
));
fields.add(new DiscordSender.EmbedField(
"Availability",
"Days: " + safe(application.availableDays()) + "\n" +
"Times: " + safe(application.availableTimes()),
false
));
fields.add(new DiscordSender.EmbedField(
"Experience",
"Previous: " + safe(application.previousExperience()) + "\n" +
"Plugins: " + safe(application.pluginExperience()) + "\n" +
"Expectations: " + safe(application.moderatorExpectations()),
false
));
if (application.additionalInfo() != null && !application.additionalInfo().isBlank()) {
fields.add(new DiscordSender.EmbedField(
"Additional Info",
application.additionalInfo(),
false
));
}
List<Long> channelIds = channels.stream()
.map(OutputChannel::channel)
.toList();
Instant timestamp = application.createdAt() != null ? application.createdAt() : Instant.now();
DiscordSender.getInstance().sendEmbedToChannels(
channelIds,
"New Staff Application Submitted",
"Join date: " + (application.joinDate() != null ? application.joinDate().toString() : "unknown") +
"\nSubmitted: " + formatInstant(timestamp),
fields,
null,
timestamp,
null
);
}
private String safe(String s) {
return s == null ? "unknown" : s;
}
private String formatInstant(Instant instant) {
if (instant == null) return "unknown";
return instant.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
}
}
@@ -0,0 +1,69 @@
package com.alttd.altitudeweb.services.mail;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@Slf4j
@Service
@RequiredArgsConstructor
public class AppealMail {
private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
@Value("${spring.mail.username}")
private String fromEmail;
private static final String APPEAL_EMAIL = "[email protected]";
/**
* Sends an email notification about the appeal to both the user and the appeals team.
*
* @param appeal The appeal object containing all necessary information
*/
public void sendAppealNotification(Appeal appeal, HistoryRecord history) {
try {
sendEmailToAppealsTeam(appeal, history);
log.info("Appeal notification emails sent successfully for appeal ID: {}", appeal.id());
} catch (Exception e) {
log.error("Failed to send appeal notification emails for appeal ID: {}", appeal.id(), e);
}
}
private void sendEmailToAppealsTeam(Appeal appeal, HistoryRecord history) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail);
helper.setTo(APPEAL_EMAIL);
helper.setReplyTo(appeal.email());
helper.setSubject("New Appeal Submitted - " + appeal.username());
Context context = new Context();
context.setVariable("appeal", appeal);
context.setVariable("history", history);
context.setVariable("createdAt", appeal.createdAt()
.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'")));
context.setVariable("active", history.getUntil() <= 0 || history.getUntil() > System.currentTimeMillis());
String content = templateEngine.process("appeal-email", context);
helper.setText(content, true);
mailSender.send(message);
}
}
@@ -0,0 +1,144 @@
package com.alttd.altitudeweb.services.mail;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.setup.Connection;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
@RequiredArgsConstructor
public class MailVerificationService {
private final JavaMailSender mailSender;
@Value("${spring.mail.username}")
private String fromEmail;
public java.util.List<EmailVerification> listAll(UUID userUuid) {
java.util.concurrent.CompletableFuture<java.util.List<EmailVerification>> future = new java.util.concurrent.CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
future.complete(mapper.findAllByUser(userUuid));
});
return future.join();
}
public EmailVerification submitEmail(UUID userUuid, String email) {
String code = generateCode();
Instant now = Instant.now();
final String finalEmail = email.toLowerCase();
CompletableFuture<EmailVerification> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
EmailVerification toPersist;
if (existing == null) {
toPersist = new EmailVerification(UUID.randomUUID(), userUuid, finalEmail, code, false, now, null, now);
mapper.insert(toPersist);
} else {
mapper.updateCodeAndLastSent(existing.id(), code, now);
toPersist = new EmailVerification(existing.id(), userUuid, finalEmail, code, false, existing.createdAt(), null, now);
}
future.complete(toPersist);
});
EmailVerification saved = future.join();
sendVerificationEmail(saved);
return saved;
}
public Optional<EmailVerification> verifyCode(UUID userUuid, String code) {
CompletableFuture<Optional<EmailVerification>> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification found = mapper.findByUserAndCode(userUuid, code);
if (found == null) {
future.complete(Optional.empty());
return;
}
mapper.markVerified(found.id(), Instant.now());
future.complete(Optional.of(found));
});
return future.join();
}
public EmailVerification resend(UUID userUuid, String email) {
String code = generateCode();
Instant now = Instant.now();
final String finalEmail = email.toLowerCase();
CompletableFuture<EmailVerification> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
if (existing != null) {
mapper.updateCodeAndLastSent(existing.id(), code, now);
future.complete(new EmailVerification(existing.id(), userUuid,
finalEmail, code, false, existing.createdAt(), null, now));
} else {
future.complete(null);
}
});
EmailVerification updated = future.join();
if (updated != null) {
sendVerificationEmail(updated);
}
return updated;
}
public boolean delete(UUID userUuid, String email) {
final String finalEmail = email.toLowerCase();
CompletableFuture<Boolean> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
if (existing != null) {
mapper.deleteByUserAndEmail(userUuid, finalEmail);
future.complete(true);
} else {
future.complete(false);
}
});
return future.join();
}
private void sendVerificationEmail(EmailVerification emailVerification) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail);
helper.setTo(emailVerification.email().toLowerCase());
helper.setSubject("Your verification code");
helper.setText("Your verification code is: " + emailVerification.verificationCode(), false);
mailSender.send(message);
} catch (MessagingException e) {
log.error("Failed to send verification email to {}", emailVerification.email(), e);
}
}
private String generateCode() {
// 6-digit numeric code
Random random = new Random();
int num = 100000 + random.nextInt(899999);
return String.valueOf(num);
}
}
@@ -0,0 +1,72 @@
package com.alttd.altitudeweb.services.mail;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@Slf4j
@Service
@RequiredArgsConstructor
public class StaffApplicationMail {
private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
@Value("${spring.mail.username}")
private String fromEmail;
private static final String STAFF_APPLICATION_EMAIL = "[email protected]";
/**
* Sends an email with the staff application details to the staff applications team mailbox.
* Returns true if the email was sent successfully.
*/
public boolean sendApplicationEmail(String username, StaffApplication application) {
try {
doSend(username, application);
log.info("Staff application email sent successfully for application ID: {}", application.id());
return true;
} catch (Exception e) {
log.error("Failed to send staff application email for application ID: {}", application.id(), e);
return false;
}
}
private void doSend(String username, StaffApplication application) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail);
helper.setTo(STAFF_APPLICATION_EMAIL);
helper.setReplyTo(application.email());
helper.setSubject("Staff Application: " + safe(application.discordUsername()));
// Prepare template context
String createdAt = application.createdAt()
.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
Context context = new Context();
context.setVariable("application", application);
context.setVariable("createdAt", createdAt);
context.setVariable("username", username);
String content = templateEngine.process("staff-application-email", context);
helper.setText(content, true);
mailSender.send(message);
}
private String safe(String s) {
return s == null ? "unknown" : s;
}
}
@@ -0,0 +1,47 @@
package com.alttd.altitudeweb.services.site;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper;
import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow;
import com.alttd.altitudeweb.mappers.VotingStatsRowToVoteDataDto;
import com.alttd.altitudeweb.model.VoteDataDto;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
@RequiredArgsConstructor
public class VoteService {
private final VotingStatsRowToVoteDataDto votingStatsRowToVoteDataDto;
public Optional<VoteDataDto> getVoteStats(UUID uuid) {
CompletableFuture<Optional<VoteDataDto>> voteDataDtoFuture = new CompletableFuture<>();
Connection.getConnection(Databases.VOTING_PLUGIN).runQuery(sqlSession -> {
try {
VotingPluginUsersMapper votingPluginUsersMapper = sqlSession.getMapper(VotingPluginUsersMapper.class);
Optional<VotingStatsRow> optionalVotingStatsRow = votingPluginUsersMapper.getStatsByUuid(uuid);
if (optionalVotingStatsRow.isEmpty()) {
log.debug("No voting stats found for {}", uuid);
voteDataDtoFuture.complete(Optional.empty());
return;
}
VotingStatsRow votingStatsRow = optionalVotingStatsRow.get();
VoteDataDto voteDataDto = votingStatsRowToVoteDataDto.map(votingStatsRow);
voteDataDtoFuture.complete(Optional.of(voteDataDto));
} catch (Exception e) {
log.error("Failed to get vote data for {}", uuid, e);
voteDataDtoFuture.completeExceptionally(e);
}
});
return voteDataDtoFuture.join();//TODO handle exception
}
}
@@ -1,9 +1,4 @@
spring.application.name=AltitudeWeb
database.name=${DB_NAME:web_db}
database.port=${DB_PORT:3306}
database.host=${DB_HOST:localhost}
database.user=${DB_USER:root}
database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:http://localhost:4200}
my-server.address=${SERVER_ADDRESS:http://localhost}
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080,http://localhost:80}
my-server.address=${SERVER_ADDRESS:http://localhost:8080}
logging.level.com.alttd.altitudeweb=DEBUG
logging.level.org.springframework.security=DEBUG
@@ -10,3 +10,11 @@ particles.file_path=${user.home}/.altitudeweb/particles
notification.server.url=${SERVER_IP:10.0.0.107}:${SERVER_PORT:8080}
my-server.address=${SERVER_ADDRESS:https://alttd.com}
logging.level.com.alttd.altitudeweb=INFO
discord.token=${DISCORD_TOKEN}
spring.mail.host=${MAIL_HOST:smtp.zoho.com}
spring.mail.port=${MAIL_PORT:465}
spring.mail.username=${MAIL_USER}
spring.mail.password=${MAIL_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.ssl.enable=true
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
<meta charset="UTF-8">
<title>Appeal Notification</title>
<style>
@font-face {
font-family: 'minecraft-title';
src: url('https://beta.alttd.com/public/fonts/minecraft-title.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/minecraft-title.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/minecraft-title.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/minecraft-title.woff') format('woff');
}
@font-face {
font-family: 'minecraft-text';
src: url('https://beta.alttd.com/public/fonts/minecraft-text.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/minecraft-text.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/minecraft-text.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/minecraft-text.woff') format('woff');
}
@font-face {
font-family: 'opensans';
src: url('https://beta.alttd.com/public/fonts/opensans.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/opensans.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/opensans.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/opensans.woff') format('woff');
}
@font-face {
font-family: 'opensans-bold';
src: url('https://beta.alttd.com/public/fonts/opensans-bold.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/opensans-bold.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/opensans-bold.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/opensans-bold.woff') format('woff');
}
body {
font-family: 'minecraft-title', sans-serif;
}
.columnSection {
width: 80%;
max-width: 800px;
margin: 0 auto;
display: flex;
}
.columnContainer {
flex: 1 1 200px;
min-width: 200px;
box-sizing: border-box;
padding: 0 15px;
}
img {
display: block;
margin: auto;
padding-top: 10px;
}
ul {
list-style-type: none;
padding-left: 0;
}
li {
padding-bottom: 7px;
}
li, p {
font-family: 'opensans', sans-serif;
font-size: 1rem;
}
h2 {
font-size: 1.5rem;
}
@media (max-width: 1150px) {
.columnContainer, .columnSection {
width: 90%;
}
}
@media (max-width: 690px) {
.columnContainer {
width: 100%;
text-align: center;
}
}
</style>
</head>
<body>
<main>
<img id="header-img" src="https://beta.alttd.com/public/img/logos/logo.png" alt="The Altitude Minecraft Server" height="159"
width="275">
<h1 style="text-align: center;" th:text="'Appeal by ' + ${appeal.username}">Appeal by Username</h1>
<section class="columnSection">
<div class="columnContainer">
<div>
<h2>User information</h2>
<ul>
<li><strong>Username:</strong> <span th:text="${appeal.username}">username</span></li>
<li><strong>UUID:</strong> <span th:text="${appeal.uuid}">uuid</span></li>
<li><strong>Email:</strong> <span th:text="${appeal.email}">email</span></li>
<li><strong>Submitted at:</strong> <span th:text="${createdAt}">date</span></li>
<li><strong>Reason:</strong> <span th:text="${history.reason}">reason</span></li>
<li><strong>Active:</strong> <span th:text="${active}">unknown</span></li>
</ul>
</div>
</div>
<div class="columnContainer">
<div>
<h2>Appeal:</h2>
<p th:text="${appeal.reason}">Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.</p>
</div>
</div>
</section>
</main>
</body>
</html>
@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
<meta charset="UTF-8">
<title>Staff Application</title>
<style>
@font-face {
font-family: 'minecraft-title';
src: url('https://beta.alttd.com/public/fonts/minecraft-title.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/minecraft-title.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/minecraft-title.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/minecraft-title.woff') format('woff');
}
@font-face {
font-family: 'minecraft-text';
src: url('https://beta.alttd.com/public/fonts/minecraft-text.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/minecraft-text.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/minecraft-text.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/minecraft-text.woff') format('woff');
}
@font-face {
font-family: 'opensans';
src: url('https://beta.alttd.com/public/fonts/opensans.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/opensans.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/opensans.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/opensans.woff') format('woff');
}
@font-face {
font-family: 'opensans-bold';
src: url('https://beta.alttd.com/public/fonts/opensans-bold.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/opensans-bold.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/opensans-bold.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/opensans-bold.woff') format('woff');
}
body {
font-family: 'minecraft-title', sans-serif;
}
.columnSection {
width: 80%;
max-width: 800px;
margin: 0 auto;
display: flex;
}
.columnContainer {
flex: 1 1 200px;
min-width: 200px;
box-sizing: border-box;
padding: 0 15px;
}
img {
display: block;
margin: auto;
padding-top: 10px;
}
ul {
list-style-type: none;
padding-left: 0;
}
li {
padding-bottom: 7px;
}
li, p {
font-family: 'opensans', sans-serif;
font-size: 1rem;
}
h2 {
font-size: 1.5rem;
}
@media (max-width: 1150px) {
.columnContainer, .columnSection {
width: 90%;
}
}
@media (max-width: 690px) {
.columnContainer {
width: 100%;
text-align: center;
}
}
</style>
</head>
<body>
<main>
<img id="header-img" src="https://beta.alttd.com/public/img/logos/logo.png" alt="The Altitude Minecraft Server" height="159" width="275">
<h1 style="text-align: center;" th:text="'Staff application by ' + ${application.discordUsername}">Staff application</h1>
<section class="columnSection">
<div class="columnContainer">
<div>
<h2>Applicant</h2>
<ul>
<li><strong>Username:</strong> <span th:text="${username}">uuid</span></li>
<li><strong>Email:</strong> <span th:text="${application.email}">email</span></li>
<li><strong>Discord:</strong> <span th:text="${application.discordUsername}">discord</span></li>
<li><strong>Age:</strong> <span th:text="${application.age}">age</span></li>
<li><strong>Pronouns:</strong> <span th:text="${application.pronouns}">pronouns</span></li>
<li><strong>Join date:</strong> <span th:text="${application.joinDate}">date</span></li>
<li><strong>Submitted at:</strong> <span th:text="${createdAt}">date</span></li>
</ul>
</div>
</div>
<div class="columnContainer">
<div>
<h2>Availability</h2>
<ul>
<li><strong>Days:</strong> <span th:text="${application.availableDays}">days</span></li>
<li><strong>Times:</strong> <span th:text="${application.availableTimes}">times</span></li>
<li><strong>Weekly playtime:</strong> <span th:text="${application.weeklyPlaytime}">0</span> hours</li>
</ul>
<h2>Experience</h2>
<p><strong>Previous:</strong><br><span th:text="${application.previousExperience}">previous experience</span></p>
<p><strong>Plugins:</strong><br><span th:text="${application.pluginExperience}">plugin experience</span></p>
<p><strong>Expectations:</strong><br><span th:text="${application.moderatorExpectations}">moderator expectations</span></p>
<div th:if="${application.additionalInfo} != null and ${application.additionalInfo} != ''">
<h2>Additional info</h2>
<p th:text="${application.additionalInfo}">additional info</p>
</div>
</div>
</div>
</section>
</main>
</body>
</html>
@@ -6,7 +6,9 @@ import lombok.Getter;
public enum Databases {
DEFAULT("web_db"),
LUCK_PERMS("luckperms"),
LITE_BANS("litebans");
LITE_BANS("litebans"),
DISCORD("discordLink"),
VOTING_PLUGIN("votingplugin");
private final String internalName;
@@ -0,0 +1,4 @@
package com.alttd.altitudeweb.database.discord;
public record OutputChannel(long guild, String outputType, long channel, String channelType) {
}
@@ -0,0 +1,15 @@
package com.alttd.altitudeweb.database.discord;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface OutputChannelMapper {
@Select("""
SELECT guild, output_type, channel, channel_type
FROM output_channels
WHERE output_type = #{outputType}
""")
List<OutputChannel> getChannelsWithOutputType(@Param("outputType") String outputType);
}
@@ -0,0 +1,64 @@
package com.alttd.altitudeweb.database.litebans;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import org.jetbrains.annotations.NotNull;
public interface EditHistoryMapper {
@Update("""
UPDATE ${table_name}
SET reason = #{reason}
WHERE id = #{id}
""")
int updateReason(@Param("table_name") String tableName,
@Param("id") int id,
@Param("reason") String reason);
@Update("""
UPDATE ${table_name}
SET until = #{until}
WHERE id = #{id}
""")
int updateUntil(@Param("table_name") String tableName,
@Param("id") int id,
@Param("until") Long until);
@Delete("""
DELETE FROM ${table_name}
WHERE id = #{id}
""")
int deletePunishment(@Param("table_name") String tableName,
@Param("id") int id);
default int setReason(@NotNull HistoryType type, int id, String reason) {
return switch (type) {
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
case BAN -> updateReason("litebans_bans", id, reason);
case MUTE -> updateReason("litebans_mutes", id, reason);
case KICK -> updateReason("litebans_kicks", id, reason);
case WARN -> updateReason("litebans_warnings", id, reason);
};
}
default int setUntil(@NotNull HistoryType type, int id, Long until) {
return switch (type) {
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
case BAN -> updateUntil("litebans_bans", id, until);
case MUTE -> updateUntil("litebans_mutes", id, until);
case KICK -> throw new IllegalArgumentException("KICK has no until");
case WARN -> throw new IllegalArgumentException("WARN has no until");
};
}
default int remove(@NotNull HistoryType type, int id) {
return switch (type) {
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
case BAN -> deletePunishment("litebans_bans", id);
case MUTE -> deletePunishment("litebans_mutes", id);
case KICK -> deletePunishment("litebans_kicks", id);
case WARN -> deletePunishment("litebans_warnings", id);
};
}
}
@@ -12,7 +12,8 @@ public interface RecentNamesMapper {
SELECT DISTINCT name AS username
FROM litebans_history
WHERE uuid = #{uuid}
ORDER BY date DESC;
ORDER BY date DESC
LIMIT 1;
""")
String getUsername(@Param("uuid") String uuid);
@@ -0,0 +1,13 @@
package com.alttd.altitudeweb.database.luckperms;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface UUIDUsernameMapper {
@Select("""
SELECT username
FROM luckperms_players
WHERE uuid = #{uuid}
""")
String getUsernameFromUUID(@Param("uuid") String uuid);
}
@@ -0,0 +1,28 @@
package com.alttd.altitudeweb.database.votingplugin;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
import java.util.UUID;
public interface VotingPluginUsersMapper {
@Select("""
SELECT
LastVotes as lastVotes,
BestDayVoteStreak as bestDailyStreak,
BestWeekVoteStreak as bestWeeklyStreak,
BestMonthVoteStreak as bestMonthlyStreak,
DayVoteStreak as dailyStreak,
WeekVoteStreak as weeklyStreak,
MonthVoteStreak as monthlyStreak,
DailyTotal as totalVotesToday,
WeeklyTotal as totalVotesThisWeek,
MonthTotal as totalVotesThisMonth,
AllTimeTotal as totalVotesAllTime
FROM votingplugin.votingplugin_users
WHERE uuid = #{uuid}
""")
Optional<VotingStatsRow> getStatsByUuid(@Param("uuid") UUID uuid);
}
@@ -0,0 +1,15 @@
package com.alttd.altitudeweb.database.votingplugin;
public record VotingStatsRow(
String lastVotes,
Integer bestDailyStreak,
Integer bestWeeklyStreak,
Integer bestMonthlyStreak,
Integer dailyStreak,
Integer weeklyStreak,
Integer monthlyStreak,
Integer totalVotesToday,
Integer totalVotesThisWeek,
Integer totalVotesThisMonth,
Integer totalVotesAllTime
) { }
@@ -5,12 +5,13 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.UUID;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PrivilegedUser {
private int id;
private String uuid;
private Integer id;
private UUID uuid;
private List<String> permissions;
}
@@ -5,6 +5,7 @@ import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface PrivilegedUserMapper {
@@ -24,7 +25,7 @@ public interface PrivilegedUserMapper {
@Result(property = "permissions", column = "id", javaType = List.class,
many = @Many(select = "getPermissionsForUser"))
})
Optional<PrivilegedUser> getUserByUuid(@Param("uuid") String uuid);
Optional<PrivilegedUser> getUserByUuid(@Param("uuid") UUID uuid);
/**
* Retrieves all privileged users with their permissions
@@ -100,4 +101,11 @@ public interface PrivilegedUserMapper {
WHERE user_id = #{userId} AND privileges = #{permission}
""")
int removePermissionFromUser(@Param("userId") int userId, @Param("permission") String permission);
@Insert("""
INSERT INTO privileged_users (uuid)
VALUES (#{uuid})
""")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int createPrivilegedUser(UUID uuid);
}
@@ -3,6 +3,6 @@ package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.Select;
public interface SettingsMapper {
@Select("SELECT host, port, name, username, password FROM db_connection_settings WHERE name = #{database}")
@Select("SELECT host, port, name, username, password FROM db_connection_settings WHERE internal_name = #{database}")
DatabaseSettings getSettings(String database);
}
@@ -0,0 +1,18 @@
package com.alttd.altitudeweb.database.web_db.forms;
import java.time.Instant;
import java.util.UUID;
public record Appeal(
UUID id,
UUID uuid,
String historyType,
Integer historyId,
String username,
String reason,
Instant createdAt,
Instant sendAt,
String email,
Long assignedTo
) {
}
@@ -0,0 +1,37 @@
package com.alttd.altitudeweb.database.web_db.forms;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.UUID;
public interface AppealMapper {
@Insert("""
INSERT INTO appeals (uuid, username, historyType, historyId, reason, created_at, send_at, e_mail, assigned_to)
VALUES (#{uuid}, #{username}, #{historyType}, #{historyId}, #{reason}, #{createdAt}, #{sendAt}, #{email}, #{assignedTo})
""")
void createAppeal(Appeal appeal);
@Select("""
SELECT id, uuid, historyType, historyId, reason, created_at AS createdAt, send_at AS sendAt, e_mail AS email, assigned_to AS assignedTo
FROM appeals
WHERE id = #{id}
""")
Appeal getAppealById(int id);
@Select("""
SELECT id, uuid, historyType, historyId, reason, created_at AS createdAt, send_at AS sendAt, e_mail AS email, assigned_to AS assignedTo
FROM appeals
WHERE uuid = #{uuid}
""")
List<Appeal> getAppealsByUuid(String uuid);
@Update("""
UPDATE appeals SET send_at = NOW()
WHERE id = #{id}
""")
void markAppealAsSent(UUID id);
}
@@ -0,0 +1,28 @@
package com.alttd.altitudeweb.database.web_db.forms;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
public record StaffApplication(
UUID id,
UUID uuid,
String email,
Integer age,
String discordUsername,
Boolean meetsRequirements,
String pronouns,
LocalDate joinDate,
Integer weeklyPlaytime,
String availableDays,
String availableTimes,
String previousExperience,
String pluginExperience,
String moderatorExpectations,
String additionalInfo,
Instant createdAt,
Instant sendAt,
Long assignedTo
) {
}
@@ -0,0 +1,29 @@
package com.alttd.altitudeweb.database.web_db.forms;
import org.apache.ibatis.annotations.*;
import java.util.UUID;
public interface StaffApplicationMapper {
@Insert("""
INSERT INTO staff_applications (
id, uuid, email, age, discord_username, meets_requirements, pronouns, join_date,
weekly_playtime, available_days, available_times, previous_experience, plugin_experience,
moderator_expectations, additional_info, created_at, send_at, assigned_to
) VALUES (
#{id}, #{uuid}, #{email}, #{age}, #{discordUsername}, #{meetsRequirements}, #{pronouns}, #{joinDate},
#{weeklyPlaytime},
#{availableDays},
#{availableTimes}, #{previousExperience}, #{pluginExperience},
#{moderatorExpectations}, #{additionalInfo}, #{createdAt}, #{sendAt}, #{assignedTo}
)
""")
void insert(StaffApplication application);
@Update("""
UPDATE staff_applications SET send_at = NOW()
WHERE id = #{id}
""")
void markAsSent(@Param("id") UUID id);
}
@@ -0,0 +1,16 @@
package com.alttd.altitudeweb.database.web_db.mail;
import java.time.Instant;
import java.util.UUID;
public record EmailVerification(
UUID id,
UUID userUuid,
String email,
String verificationCode,
boolean verified,
Instant createdAt,
Instant verifiedAt,
Instant lastSentAt
) {
}
@@ -0,0 +1,58 @@
package com.alttd.altitudeweb.database.web_db.mail;
import org.apache.ibatis.annotations.*;
import java.time.Instant;
import java.util.UUID;
public interface EmailVerificationMapper {
@Insert("""
INSERT INTO user_emails (id, user_uuid, email, verification_code, verified, created_at, last_sent_at)
VALUES (#{id}, #{userUuid}, #{email}, #{verificationCode}, #{verified}, #{createdAt}, #{lastSentAt})
""")
void insert(EmailVerification emailVerification);
@Select("""
SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified,
created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt
FROM user_emails
WHERE user_uuid = #{userUuid} AND email = #{email}
""")
EmailVerification findByUserAndEmail(@Param("userUuid") UUID userUuid, @Param("email") String email);
@Select("""
SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified,
created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt
FROM user_emails
WHERE user_uuid = #{userUuid} AND verification_code = #{code}
ORDER BY created_at DESC LIMIT 1
""")
EmailVerification findByUserAndCode(@Param("userUuid") UUID userUuid, @Param("code") String code);
@Select("""
SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified,
created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt
FROM user_emails
WHERE user_uuid = #{userUuid}
ORDER BY created_at ASC
""")
java.util.List<EmailVerification> findAllByUser(@Param("userUuid") UUID userUuid);
@Update("""
UPDATE user_emails SET verified = 1, verified_at = #{verifiedAt}
WHERE id = #{id}
""")
void markVerified(@Param("id") UUID id, @Param("verifiedAt") Instant verifiedAt);
@Update("""
UPDATE user_emails SET verification_code = #{code}, last_sent_at = #{lastSentAt}, verified = 0, verified_at = NULL
WHERE id = #{id}
""")
void updateCodeAndLastSent(@Param("id") UUID id, @Param("code") String code, @Param("lastSentAt") Instant lastSentAt);
@Delete("""
DELETE FROM user_emails WHERE user_uuid = #{userUuid} AND email = #{email}
""")
void deleteByUserAndEmail(@Param("userUuid") UUID userUuid, @Param("email") String email);
}
@@ -3,6 +3,7 @@ package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.DatabaseSettings;
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
import com.alttd.altitudeweb.type_handler.UUIDTypeHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.datasource.pooled.PooledDataSource;
import org.apache.ibatis.mapping.Environment;
@@ -11,9 +12,12 @@ import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Consumer;
@Slf4j
@@ -33,6 +37,8 @@ public class Connection {
InitializeWebDb.init();
InitializeLiteBans.init();
InitializeLuckPerms.init();
InitializeDiscord.init();
InitializeVotingPlugin.init();
}
@FunctionalInterface
@@ -54,25 +60,46 @@ public class Connection {
if (database == Databases.DEFAULT) {
return loadDefaultDatabase(addMappers);
}
CompletableFuture<DatabaseSettings> settingsFuture = new CompletableFuture<>();
getConnection(Databases.DEFAULT, (mapper -> mapper.addMapper(SettingsMapper.class))).thenApply(connection -> {
log.debug("Loading settings for database {}", database.getInternalName());
CompletableFuture<DatabaseSettings> settingsFuture = new CompletableFuture<>();
getConnection(Databases.DEFAULT, (mapper -> mapper.addMapper(SettingsMapper.class))).thenApply(connection -> {
connection.runQuery(session -> {
log.debug("Running query to load settings for database");
try {
log.debug("Running query to load settings for database {}", database.getInternalName());
DatabaseSettings loadedSettings = session.getMapper(SettingsMapper.class).getSettings(database.getInternalName());
if (loadedSettings == null) {
log.error("Failed to load settings for database {}", database.getInternalName());
}
log.debug("Loaded settings {}", loadedSettings);
log.error("Failed to load settings for database {}. No settings found in db_connection_settings table.",
database.getInternalName());
settingsFuture.completeExceptionally(new IllegalStateException(
"Database settings for " + database.getInternalName() + " not found in db_connection_settings table"));
} else {
log.debug("Loaded settings for database {}: host={}, port={}, name={}",
database.getInternalName(), loadedSettings.host(), loadedSettings.port(), loadedSettings.name());
settingsFuture.complete(loadedSettings);
}
} catch (Exception e) {
log.error("Error occurred while loading database settings for {}", database.getInternalName(), e);
settingsFuture.completeExceptionally(e);
}
});
return null;
}).exceptionally(ex -> {
log.error("Failed to access DEFAULT database to load settings for {}", database.getInternalName(), ex);
settingsFuture.completeExceptionally(ex);
return null;
});
return settingsFuture.thenApply(loadedSettings -> {
log.debug("Storing connection for database {}", database.getInternalName());
Connection connection = new Connection(loadedSettings, addMappers);
connections.put(database, connection);
return connection;
}).exceptionally(ex -> {
log.error("Failed to create connection for database {}", database.getInternalName(), ex);
throw new CompletionException("Failed to initialize database connection for " + database.getInternalName(), ex);
});
}
@@ -116,17 +143,47 @@ public class Connection {
}
private SqlSessionFactory createSqlSessionFactory(DatabaseSettings settings, AddMappers addMappers) {
PooledDataSource dataSource = new PooledDataSource();
dataSource.setDriver("com.mysql.cj.jdbc.Driver");
dataSource.setUrl(String.format("jdbc:mysql://%s:%d/%s", settings.host(),
settings.port(), settings.name()));
dataSource.setUsername(settings.username());
dataSource.setPassword(settings.password());
Environment environment = new Environment("production", new JdbcTransactionFactory(), dataSource);
Configuration configuration = new Configuration(environment);
try {
Configuration configuration = getConfiguration(settings);
configuration.getTypeHandlerRegistry().register(UUID.class, UUIDTypeHandler.class);
addMappers.apply(configuration);
return new SqlSessionFactoryBuilder().build(configuration);
} catch (Exception e) {
log.error("""
Failed to create sql session factory with
\thost {}
\tport: {}
\tname: {}
\tusername: {}
""", settings.host(), settings.port(), settings.name(), settings.username(), e);
throw e;
}
}
private static @NotNull Configuration getConfiguration(DatabaseSettings settings) {
PooledDataSource dataSource = new PooledDataSource();
dataSource.setDriver("com.mysql.cj.jdbc.Driver");
String url = String.format(
"jdbc:mysql://%s:%d/%s?useSSL=true&tcpKeepAlive=true&socketTimeout=60000&connectTimeout=10000&autoReconnect=false&useUnicode=true&characterEncoding=utf8",
settings.host(),
settings.port(),
settings.name()
);
dataSource.setUrl(url);
dataSource.setUsername(settings.username());
dataSource.setPassword(settings.password());
dataSource.setPoolMaximumActiveConnections(10);
dataSource.setPoolMaximumIdleConnections(5);
dataSource.setPoolTimeToWait(20000);
dataSource.setPoolPingEnabled(true);
dataSource.setPoolPingQuery("SELECT 1");
dataSource.setPoolPingConnectionsNotUsedFor(300000); // 5 min
Environment environment = new Environment("production", new JdbcTransactionFactory(), dataSource);
return new Configuration(environment);
}
}
@@ -0,0 +1,19 @@
package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class InitializeDiscord {
protected static void init() {
log.info("Initializing Discord");
Connection.getConnection(Databases.DISCORD, (configuration) -> {
configuration.addMapper(OutputChannelMapper.class);
}).join();
log.debug("Initialized Discord");
}
}
@@ -19,6 +19,7 @@ public class InitializeLiteBans {
configuration.addMapper(UUIDHistoryMapper.class);
configuration.addMapper(HistoryCountMapper.class);
configuration.addMapper(IdHistoryMapper.class);
configuration.addMapper(EditHistoryMapper.class);
}).join()
.runQuery(sqlSession -> {
createAllPunishmentsView(sqlSession);
@@ -2,6 +2,7 @@ package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@@ -11,6 +12,7 @@ public class InitializeLuckPerms {
log.info("Initializing LuckPerms");
Connection.getConnection(Databases.LUCK_PERMS, (configuration) -> {
configuration.addMapper(TeamMemberMapper.class);
configuration.addMapper(UUIDUsernameMapper.class);
}).join();
log.debug("Initialized LuckPerms");
}
@@ -0,0 +1,17 @@
package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class InitializeVotingPlugin {
protected static void init() {
log.info("Initializing VotingPlugin");
Connection.getConnection(Databases.VOTING_PLUGIN, (configuration) -> {
configuration.addMapper(VotingPluginUsersMapper.class);
}).join();
log.debug("Initialized VotingPlugin");
}
}
@@ -4,6 +4,9 @@ import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.jetbrains.annotations.NotNull;
@@ -20,12 +23,18 @@ public class InitializeWebDb {
configuration.addMapper(SettingsMapper.class);
configuration.addMapper(KeyPairMapper.class);
configuration.addMapper(PrivilegedUserMapper.class);
configuration.addMapper(AppealMapper.class);
configuration.addMapper(StaffApplicationMapper.class);
configuration.addMapper(EmailVerificationMapper.class);
}).join()
.runQuery(SqlSession -> {
createSettingsTable(SqlSession);
createKeyTable(SqlSession);
createPrivilegedUsersTable(SqlSession);
createPrivilegesTable(SqlSession);
.runQuery(sqlSession -> {
createSettingsTable(sqlSession);
createKeyTable(sqlSession);
createPrivilegedUsersTable(sqlSession);
createPrivilegesTable(sqlSession);
createAppealTable(sqlSession);
createStaffApplicationsTable(sqlSession);
createUserEmailsTable(sqlSession);
});
log.debug("Initialized WebDb");
}
@@ -70,7 +79,7 @@ public class InitializeWebDb {
String query = """
CREATE TABLE IF NOT EXISTS privileged_users (
id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
uuid VARCHAR(36) NOT NULL
uuid UUID UNIQUE NOT NULL
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
@@ -99,4 +108,79 @@ public class InitializeWebDb {
}
}
private static void createUserEmailsTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS user_emails (
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
user_uuid UUID NOT NULL,
email VARCHAR(255) NOT NULL,
verification_code VARCHAR(16) NOT NULL,
verified BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
verified_at TIMESTAMP NULL,
last_sent_at TIMESTAMP NULL,
FOREIGN KEY (user_uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private static void createStaffApplicationsTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS staff_applications (
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
uuid UUID NOT NULL,
email VARCHAR(320) NOT NULL,
age INT NOT NULL,
discord_username VARCHAR(32) NOT NULL,
meets_requirements BOOLEAN NOT NULL,
pronouns VARCHAR(32) NULL,
join_date DATE NOT NULL,
weekly_playtime INT NOT NULL,
available_days TEXT NOT NULL,
available_times TEXT NOT NULL,
previous_experience TEXT NOT NULL,
plugin_experience TEXT NOT NULL,
moderator_expectations TEXT NOT NULL,
additional_info TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
send_at TIMESTAMP NULL,
assigned_to BIGINT UNSIGNED NULL,
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private static void createAppealTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS appeals (
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
uuid UUID NOT NULL,
historyType VARCHAR(16) NOT NULL,
historyId BIGINT UNSIGNED NOT NULL,
username VARCHAR(16) NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
send_at TIMESTAMP NULL,
e_mail TEXT NOT NULL,
assigned_to BIGINT UNSIGNED NULL,
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
plugins {
id("java")
}
group = "com.alttd.webinterface"
version = "unspecified"
repositories {
mavenCentral()
}
dependencies {
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
// JDA
implementation("net.dv8tion:JDA:6.0.0-rc.2") {
exclude("opus-java") // exclude audio
exclude("tink") // exclude audio
}
compileOnly("org.projectlombok:lombok:1.18.38")
annotationProcessor("org.projectlombok:lombok:1.18.38")
}
tasks.test {
useJUnitPlatform()
}
@@ -0,0 +1,18 @@
package com.alttd.webinterface;
import com.alttd.webinterface.bot.DiscordBotInstance;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class DiscordBot {
public static void main(String[] args) {
String discordToken = System.getProperty("DISCORD_TOKEN");
if (discordToken == null) {
log.error("Discord token not found, put it in the DISCORD_TOKEN environment variable");
System.exit(1);
}
DiscordBotInstance discordBotInstance = new DiscordBotInstance();
discordBotInstance.start(discordToken);
}
}
@@ -0,0 +1,34 @@
package com.alttd.webinterface.bot;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.requests.GatewayIntent;
@Slf4j
public class DiscordBotInstance {
@Getter
private JDA jda;
private volatile boolean ready = false;
public synchronized void start(String token) {
if (jda != null) {
return;
}
jda = JDABuilder.createDefault(token,
GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_PRESENCES,
GatewayIntent.GUILD_MESSAGES,
GatewayIntent.MESSAGE_CONTENT)
.addEventListeners(new ReadyListener(() -> {
ready = true;
}))
.build();
}
public boolean isReady() {
return ready && jda != null && jda.getStatus() == JDA.Status.CONNECTED;
}
}
@@ -0,0 +1,26 @@
package com.alttd.webinterface.bot;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.events.session.ReadyEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;
@Slf4j
@RequiredArgsConstructor
public class ReadyListener extends ListenerAdapter {
private final Runnable onReadyCallback;
@Override
public void onReady(@NotNull ReadyEvent event) {
log.info("JDA is ready. Guilds loaded: {}", event.getJDA().getGuilds().size());
if (onReadyCallback != null) {
try {
onReadyCallback.run();
} catch (Exception e) {
log.error("Error running onReady callback", e);
}
}
}
}
@@ -0,0 +1,133 @@
package com.alttd.webinterface.send_message;
import com.alttd.webinterface.bot.DiscordBotInstance;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import java.awt.*;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@Slf4j
public class DiscordSender {
private static final DiscordSender INSTANCE = new DiscordSender();
private final DiscordBotInstance botInstance = new DiscordBotInstance();
private DiscordSender() {}
public static DiscordSender getInstance() {
return INSTANCE;
}
private void ensureStarted() {
if (botInstance.getJda() != null) return;
String token = Optional.ofNullable(System.getenv("DISCORD_TOKEN"))
.orElse(System.getProperty("DISCORD_TOKEN"));
if (token == null || token.isBlank()) {
log.error("Discord token not found. Set DISCORD_TOKEN as an environment variable or system property.");
return;
}
botInstance.start(token);
}
public void sendMessageToChannels(List<Long> channelIds, String message) {
ensureStarted();
if (botInstance.getJda() == null) {
log.error("JDA not initialized; cannot send Discord message.");
return;
}
try {
if (!botInstance.isReady()) {
botInstance.getJda().awaitReady();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
log.warn("Error while waiting for JDA ready state", e);
}
channelIds.stream()
.filter(Objects::nonNull)
.forEach(id -> {
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id);
if (channel == null) {
log.warn("TextChannel with id {} not found", id);
return;
}
channel.sendMessage(message).queue(
success -> log.debug("Sent message to channel {}", id),
error -> log.error("Failed sending message to channel {}", id, error)
);
});
}
public void sendEmbedToChannels(List<Long> channelIds, String title, String description, List<EmbedField> fields,
Integer colorRgb, Instant timestamp, String footer) {
ensureStarted();
if (botInstance.getJda() == null) {
log.error("JDA not initialized; cannot send Discord embed.");
return;
}
try {
if (!botInstance.isReady()) {
botInstance.getJda().awaitReady();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
log.warn("Error while waiting for JDA ready state", e);
}
EmbedBuilder eb = new EmbedBuilder();
if (title != null && !title.isBlank()) eb.setTitle(title);
if (description != null && !description.isBlank()) eb.setDescription(description);
if (colorRgb != null) eb.setColor(new Color(colorRgb)); else eb.setColor(new Color(0xFF8C00)); // default orange
eb.setTimestamp(timestamp != null ? timestamp : Instant.now());
if (footer != null && !footer.isBlank()) eb.setFooter(footer);
if (fields != null) {
for (EmbedField f : fields) {
if (f == null) continue;
String name = f.getName() == null ? "" : f.getName();
String value = f.getValue() == null ? "" : f.getValue();
// JDA field value max is 1024; truncate to be safe
if (value.length() > 1024) value = value.substring(0, 1021) + "...";
eb.addField(new MessageEmbed.Field(name, value, f.isInline()));
}
}
MessageEmbed embed = eb.build();
channelIds.stream()
.filter(Objects::nonNull)
.forEach(id -> {
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id);
if (channel == null) {
log.warn("TextChannel with id {} not found", id);
return;
}
channel.sendMessageEmbeds(embed).queue(
success -> log.debug("Sent embed to channel {}", id),
error -> log.error("Failed sending embed to channel {}", id, error)
);
});
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class EmbedField {
private String name;
private String value;
private boolean inline;
}
}
+3
View File
@@ -76,6 +76,9 @@
}
},
"serve": {
"options": {
"proxyConfig": "proxy.conf.json"
},
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
+7
View File
@@ -0,0 +1,7 @@
{
"/api": {
"target": "http://localhost:8080",
"secure": false,
"changeOrigin": true
}
}
+28 -2
View File
@@ -106,6 +106,15 @@ export const routes: Routes = [
path: 'staffpowers',
loadComponent: () => import('./pages/reference/staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
},
{
path: 'forms',
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
},
{
path: 'appeal',
redirectTo: 'forms/appeal',
pathMatch: 'full'
},
{
path: 'forms/appeal',
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
@@ -115,8 +124,25 @@ export const routes: Routes = [
}
},
{
path: 'forms',
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
path: 'forms/sent',
loadComponent: () => import('./pages/forms/sent/sent.component').then(m => m.SentComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_user']
}
},
{
path: 'apply',
redirectTo: 'forms/staff-application',
pathMatch: 'full'
},
{
path: 'forms/staff-application',
loadComponent: () => import('./pages/forms/staff-application/staff-application.component').then(m => m.StaffApplicationComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_user']
}
},
{
path: 'community',
+16 -6
View File
@@ -1,8 +1,9 @@
import {Injectable} from '@angular/core';
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 {environment} from '@environment';
import {MatDialog} from '@angular/material/dialog';
import {LoginDialogComponent} from '@shared-components/login/login.component';
@Injectable({
providedIn: 'root'
@@ -11,7 +12,8 @@ export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
private router: Router,
private dialog: MatDialog
) {
}
@@ -19,11 +21,19 @@ export class AuthGuard implements CanActivate {
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
if (environment.defaultAuthStatus) {
return true;
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]);
}
if (!this.authService.checkAuthStatus()) {
return this.router.createUrlTree(['/']);
})
);
}
const requiredAuthorizations = route.data['requiredAuthorizations'] as string[];
@@ -1,11 +1,10 @@
import {Component} from '@angular/core';
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 {HeaderComponent} from '@header/header.component';
import {CookieService} from 'ngx-cookie-service';
import {map, Observable, shareReplay} from 'rxjs';
import {environment} from '@environment';
@Component({
selector: 'app-team',
@@ -17,7 +16,6 @@ import {environment} from '@environment';
],
providers: [
CookieService,
{provide: BASE_PATH, useValue: environment.apiUrl}
],
templateUrl: './team.component.html',
@@ -6,9 +6,9 @@
<h1>Dynamic Server Maps</h1>
<h2>Which server would you like to see?</h2>
<div>
<a href="https://gmap.alttd.com">
<a href="https://bmap.alttd.com">
<div style="background-color: #F547B2;" class="button-outer map-button">
<span class="button-inner">Grove</span>
<span class="button-inner">Bayou</span>
</div>
</a>
<a href="https://cmap.alttd.com">
@@ -75,10 +75,13 @@
<section class="formPage">
<div class="description">
<h2>Please enter your email.</h2>
<p style="font-style: italic">It does not have to be your minecraft email.</p>
<p style="font-style: italic">It does not have to be your minecraft email. You will have to verify
it</p>
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Email</mat-label>
<input matInput formControlName="email" placeholder="Email">
<input matInput
formControlName="email"
placeholder="Email">
@if (form.controls.email.invalid && form.controls.email.touched) {
<mat-error>
@if (form.controls.email.errors?.['required']) {
@@ -89,8 +92,16 @@
</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, and can continue to the next page!</span>
</ng-container>
</div>
<button mat-raised-button (click)="nextPage()" [disabled]="form.controls.email.invalid">
}
</div>
<button mat-raised-button (click)="validateMailOrNextPage()" [disabled]="form.controls.email.invalid">
Next
</button>
</section>
@@ -89,3 +89,23 @@ main {
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);
}
.valid-email mat-icon {
color: #4CAF50;
margin-right: 10px;
}
.valid-email span {
color: #4CAF50;
font-weight: 500;
}
@@ -1,6 +1,16 @@
import {AfterViewInit, Component, ElementRef, OnInit, Renderer2, signal} from '@angular/core';
import {
AfterViewInit,
Component,
computed,
ElementRef,
inject,
OnDestroy,
OnInit,
Renderer2,
signal
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {AppealsService, HistoryService, MinecraftAppeal, PunishmentHistory} from '@api';
import {AppealsService, EmailEntry, HistoryService, MailService, MinecraftAppeal, PunishmentHistory} from '@api';
import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common';
import {MatButtonModule} from '@angular/material/button';
@@ -11,6 +21,9 @@ import {MatFormFieldModule} from '@angular/material/form-field';
import {MatSelectModule} from '@angular/material/select';
import {MatInputModule} from '@angular/material/input';
import {HistoryFormatService} from '@pages/reference/bans/history-format.service';
import {MatDialog} from '@angular/material/dialog';
import {VerifyMailDialogComponent} from '@pages/forms/verify-mail-dialog/verify-mail-dialog.component';
import {Router} from '@angular/router';
@Component({
selector: 'app-appeal',
@@ -28,19 +41,27 @@ import {HistoryFormatService} from '@pages/reference/bans/history-format.service
templateUrl: './appeal.component.html',
styleUrl: './appeal.component.scss'
})
export class AppealComponent implements OnInit, AfterViewInit {
export class AppealComponent implements OnInit, OnDestroy, AfterViewInit {
public form: FormGroup<Appeal>;
private mailService = inject(MailService);
private historyFormatService = inject(HistoryFormatService);
private appealsService = inject(AppealsService);
private historyService = inject(HistoryService);
public authService = inject(AuthService);
private resizeObserver: ResizeObserver | null = null;
private boundHandleResize: any;
protected form: FormGroup<Appeal>;
protected history = signal<PunishmentHistory[] | null>(null);
protected selectedPunishment = signal<PunishmentHistory | null>(null);
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);
constructor(
private historyFormatService: HistoryFormatService,
private appealApi: AppealsService,
private historyApi: HistoryService,
protected authService: AuthService,
private elementRef: ElementRef,
private renderer: Renderer2
) {
@@ -48,6 +69,22 @@ export class AppealComponent implements OnInit, AfterViewInit {
email: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.email]}),
appeal: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.minLength(10)]})
});
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() {
@@ -55,7 +92,7 @@ export class AppealComponent implements OnInit, AfterViewInit {
if (uuid === null) {
throw new Error('JWT subject is null, are you logged in?');
}
this.historyApi.getAllHistoryForUUID(uuid).subscribe(history => {
this.historyService.getAllHistoryForUUID(uuid).subscribe(history => {
this.history.set(history.filter(item => this.historyFormatService.isActive(item)));
})
}
@@ -135,6 +172,8 @@ export class AppealComponent implements OnInit, AfterViewInit {
}
}
private router = inject(Router)
private sendForm() {
const rawValue = this.form.getRawValue();
const uuid = this.authService.getUuid();
@@ -145,10 +184,18 @@ export class AppealComponent implements OnInit, AfterViewInit {
appeal: rawValue.appeal,
email: rawValue.email,
punishmentId: this.selectedPunishment()!.id,
punishmentType: this.selectedPunishment()!.type,
username: this.authService.username()!,
uuid: uuid
}
this.appealApi.submitMinecraftAppeal(appeal).subscribe()
this.appealsService.submitMinecraftAppeal(appeal).subscribe((result) => {
if (!result.verified_mail) {
throw new Error('Mail not verified');
}
this.router.navigate(['/forms/sent'], {
state: {message: result.message}
}).then();
})
}
public currentPageIndex: number = 0;
@@ -184,6 +231,21 @@ export class AppealComponent implements OnInit, AfterViewInit {
onPunishmentSelected($event: PunishmentHistory) {
this.selectedPunishment.set($event);
}
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);
}
});
}
}
interface Appeal {
@@ -17,6 +17,14 @@
</p>
</a>
</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>
</section>
</section>
@@ -0,0 +1,13 @@
<div>
<app-header [current_page]="'appeal'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Form completed</h1>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<p>{{ message }}</p>
</section>
</main>
</div>
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SentComponent } from './sent.component';
describe('SentComponent', () => {
let component: SentComponent;
let fixture: ComponentFixture<SentComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SentComponent]
})
.compileComponents();
fixture = TestBed.createComponent(SentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,26 @@
import {Component, inject, OnInit} from '@angular/core';
import {Router} from '@angular/router';
import {HeaderComponent} from '@header/header.component';
@Component({
selector: 'app-sent',
imports: [
HeaderComponent
],
templateUrl: './sent.component.html',
styleUrl: './sent.component.scss',
standalone: true
})
export class SentComponent implements OnInit {
protected message: string = "The form is completed and has been sent";
private router: Router = inject(Router)
ngOnInit() {
const state = this.router.getCurrentNavigation()?.extras.state || history.state;
if (state && 'message' in state) {
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>;
}
@@ -0,0 +1,48 @@
<h2 mat-dialog-title>Email Verification</h2>
<div mat-dialog-content>
<p>Please enter the 6-character verification code sent to: <strong>{{ email }}</strong></p>
<form [formGroup]="form">
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Verification Code</mat-label>
<input matInput formControlName="code" placeholder="Enter 6-character code">
@if (form.controls.code.invalid && form.controls.code.touched) {
<mat-error>
@if (form.controls.code.errors?.['required']) {
Verification code is required
} @else if (form.controls.code.errors?.['minlength'] || form.controls.code.errors?.['maxlength']) {
Code must be exactly 6 characters
} @else {
Please enter the 6-character code we sent you.
}
</mat-error>
}
</mat-form-field>
</form>
@if (mailVerified()) {
<p class="success-message">Email verified successfully!</p>
}
</div>
<div mat-dialog-actions align="end">
<button mat-button (click)="onCancel()">Cancel</button>
<button mat-button
color="accent"
(click)="onResend()"
[disabled]="resendCooldown()">
@if (resendCooldown()) {
Resend ({{ cooldownSeconds() }}s)
} @else {
Resend Code
}
</button>
<button mat-flat-button
color="primary"
(click)="onSubmit()"
[disabled]="form.invalid">
Submit
</button>
</div>
@@ -0,0 +1,23 @@
:host {
display: block;
}
.mat-dialog-content {
min-height: 120px;
max-width: 400px;
}
.success-message {
color: #4caf50;
font-weight: 500;
margin-top: 16px;
}
mat-form-field {
width: 100%;
margin-bottom: 16px;
}
button {
margin-left: 8px;
}
@@ -0,0 +1,161 @@
import {Component, Inject, inject, input, signal} from '@angular/core';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms';
import {MatInput, MatLabel} from '@angular/material/input';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MailService, SubmitEmail, VerifyCode} from '@api';
import {AuthService} from '@services/auth.service';
import {MatButtonModule} from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from '@angular/material/dialog';
import {interval, Subscription} from 'rxjs';
@Component({
selector: 'app-verify-mail-dialog',
imports: [
FormsModule,
MatFormFieldModule,
MatInput,
MatLabel,
ReactiveFormsModule,
MatButtonModule,
MatDialogTitle,
MatDialogContent,
MatDialogActions
],
templateUrl: './verify-mail-dialog.component.html',
styleUrl: './verify-mail-dialog.component.scss'
})
export class VerifyMailDialogComponent {
protected form: FormGroup<VerifyMail>;
protected readonly completionMessage = input<string>("Thank you for completing your form!");
protected readonly verifyMail = input<VerifyMailData | null>(null);
protected mailVerified = signal<boolean>(false);
// For resend cooldown
protected resendCooldown = signal<boolean>(false);
protected cooldownSeconds = signal<number>(60);
private cooldownSubscription: Subscription | null = null;
private mailService = inject(MailService);
private authService = inject(AuthService);
protected readonly email: string;
constructor(
public dialogRef: MatDialogRef<VerifyMailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: InputMail
) {
this.form = new FormGroup({
code: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(6), Validators.maxLength(6)]
})
});
this.email = data.email;
this.mailService.submitEmailForVerification({email: this.email.toLowerCase()}).subscribe();
}
public onSubmit() {
if (this.form === undefined) {
console.error('Form is undefined');
return;
}
if (this.form.valid) {
this.sendForm();
} else {
Object.keys(this.form.controls).forEach(field => {
const control = this.form!.get(field);
control?.markAsTouched({onlySelf: true});
});
}
}
public onCancel() {
this.dialogRef.close(false);
}
public onResend() {
if (this.resendCooldown()) {
return;
}
const submitEmail: SubmitEmail = {
email: this.email
};
this.mailService.resendVerificationEmail(submitEmail).subscribe({
next: (response) => {
this.startResendCooldown();
},
error: (error) => {
console.error('Error resending verification email', error);
}
});
}
private startResendCooldown() {
this.resendCooldown.set(true);
this.cooldownSeconds.set(60);
if (this.cooldownSubscription) {
this.cooldownSubscription.unsubscribe();
}
this.cooldownSubscription = interval(1000).subscribe(() => {
const currentSeconds = this.cooldownSeconds();
if (currentSeconds <= 1) {
this.resendCooldown.set(false);
this.cooldownSubscription?.unsubscribe();
this.cooldownSubscription = null;
} else {
this.cooldownSeconds.set(currentSeconds - 1);
}
});
}
private sendForm() {
const rawValue = this.form.getRawValue();
if (this.authService.isAuthenticated$()) {
const form: VerifyCode = {
code: rawValue.code,
};
this.mailService.verifyEmailCode(form).subscribe({
next: (mailResponse) => {
this.mailVerified.set(mailResponse.verified);
if (mailResponse.verified) {
this.dialogRef.close(true);
}
},
error: (error) => {
console.error('Error verifying email code', error);
}
});
} else {
throw new Error('User not logged in');
}
}
ngOnDestroy() {
if (this.cooldownSubscription) {
this.cooldownSubscription.unsubscribe();
}
}
}
interface VerifyMail {
code: FormControl<string>;
}
interface VerifyMailData {
verified: boolean;
mail: string;
}
interface InputMail {
email: string;
}
@@ -62,7 +62,7 @@
<li><a href="https://alttd.com/blog/">Blog</a></li>
</ul>
</li>
@if (!isAuthenticated) {
@if (!isAuthenticated()) {
<li>
<a (click)="openLoginDialog()">
Login
@@ -137,16 +137,18 @@
<li class="nav_li"><a class="nav_link2" target="_blank" href="https://alttd.com/blog/">Blog</a></li>
</ul>
</li>
@if (isAuthenticated) {
@if (isAuthenticated()) {
<li class="nav_li">
<a [id]="getCurrentPageId(['particles'])"
class="nav_link fake_link" [ngClass]="active">Special</a>
<ul class="dropdown">
@if (hasAccess([PermissionClaim.HEAD_MOD])) {
<li class="nav_li"><a class="nav_link2" [routerLink]="['/particles']">Particles</a></li>
}
</ul>
</li>
}
@if (!isAuthenticated) {
@if (!isAuthenticated()) {
<li class="nav_li login-button">
<a class="nav_link fake_link" (click)="openLoginDialog()">
Login
@@ -1,4 +1,4 @@
import {Component, HostListener, inject, Input, OnDestroy, OnInit} from '@angular/core';
import {Component, computed, HostListener, inject, Input, OnDestroy, Signal} from '@angular/core';
import {CommonModule, NgOptimizedImage} from '@angular/common';
import {ThemeComponent} from '@shared-components/theme/theme.component';
import {RouterLink} from '@angular/router';
@@ -6,6 +6,7 @@ import {AuthService} from '@services/auth.service';
import {Subscription} from 'rxjs';
import {LoginDialogComponent} from '@shared-components/login/login.component';
import {MatDialog} from '@angular/material/dialog';
import {PermissionClaim} from '@api';
@Component({
standalone: true,
@@ -19,7 +20,7 @@ import {MatDialog} from '@angular/material/dialog';
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnInit, OnDestroy {
export class HeaderComponent implements OnDestroy {
private authService: AuthService = inject(AuthService)
private dialog: MatDialog = inject(MatDialog)
@@ -32,14 +33,7 @@ export class HeaderComponent implements OnInit, OnDestroy {
public active: string = '';
public inverseYPos: number = 0;
private subscription: Subscription | undefined;
public isAuthenticated: boolean = false;
ngOnInit(): void {
this.subscription = this.authService.isAuthenticated$.subscribe(isAuthenticated => {
this.isAuthenticated = isAuthenticated;
}
);
}
public isAuthenticated: Signal<boolean> = computed(() => this.authService.isAuthenticated$());
ngOnDestroy(): void {
this.subscription?.unsubscribe();
@@ -97,4 +91,6 @@ export class HeaderComponent implements OnInit, OnDestroy {
protected logout() {
this.authService.logout()
}
protected readonly PermissionClaim = PermissionClaim;
}
@@ -14,8 +14,11 @@
</app-header>
<main>
<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>
} @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>
<div style="padding-top: 35px;">
<app-copy-ip></app-copy-ip>
+35 -4
View File
@@ -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 {ALTITUDE_VERSION} from '@custom-types/constant';
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 {RouterLink} from '@angular/router';
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({
standalone: true,
@@ -24,9 +34,16 @@ import {JwtHelperService} from '@auth0/angular-jwt';
templateUrl: './home.component.html',
styleUrl: './home.component.scss'
})
export class HomeComponent implements OnInit {
constructor(private titleService: Title, public scrollService: ScrollService) {
}
export class HomeComponent implements OnInit, OnDestroy {
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",
"/public/img/backgrounds/caruselimage4.png",
@@ -44,6 +61,10 @@ export class HomeComponent implements OnInit {
this.randomizeSlides()
}
ngOnDestroy(): void {
this.playerCountUpdateInterval.unsubscribe();
}
private randomizeSlides(): void {
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;
}
@@ -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>
@@ -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;
}
@@ -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 {
let uuid = entry.replace('-', '');
if (uuid === 'C') {
if (uuid === 'C' || uuid === 'Console' || uuid === '[Console]') {
uuid = "f78a4d8dd51b4b3998a3230f2de0c670"
}
return `https://crafatar.com/avatars/${uuid}?size=${size}&overlay`;
@@ -13,6 +13,9 @@
<th class="historyReason">Reason</th>
<th class="historyDate">Date</th>
<th class="historyDate">Expires</th>
@if (canEdit()) {
<th class="historyActions"></th>
}
</tr>
</thead>
</div>
@@ -32,7 +35,8 @@
</td>
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
<div class="playerContainer">
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25" height="25"
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25"
height="25"
alt="{{entry.punishedBy}}'s Minecraft skin">
<span>{{ entry.punishedBy }}</span>
</div>
@@ -46,6 +50,11 @@
<td class="historyDate" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getExpiredTime(entry) }}
</td>
@if (canEdit()) {
<td class="historyActions" (click)="openEdit(entry)">
<mat-icon>edit</mat-icon>
</td>
}
</tr>
}
</tbody>
@@ -111,3 +111,8 @@ img {
.historyDate {
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 {BASE_PATH, HistoryService, PunishmentHistory} from '@api';
import {Component, EventEmitter, inject, Input, OnChanges, OnInit, Output} from '@angular/core';
import {HistoryService, PunishmentHistory} from '@api';
import {catchError, map, Observable, shareReplay} from 'rxjs';
import {NgOptimizedImage} from '@angular/common';
import {CookieService} from 'ngx-cookie-service';
import {RemoveTrailingPeriodPipe} from '@pipes/RemoveTrailingPeriodPipe';
import {HttpErrorResponse} from '@angular/common/http';
import {environment} from '@environment';
import {HistoryFormatService} from '../history-format.service';
import {SearchParams} from '../search-terms';
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({
selector: 'app-history',
imports: [
NgOptimizedImage,
RemoveTrailingPeriodPipe
RemoveTrailingPeriodPipe,
MatIconModule,
],
templateUrl: './history.component.html',
styleUrl: './history.component.scss',
providers: [
CookieService,
{provide: BASE_PATH, useValue: environment.apiUrl}
],
})
export class HistoryComponent implements OnInit, OnChanges {
@@ -37,8 +40,11 @@ export class HistoryComponent implements OnInit, OnChanges {
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 {
this.reloadHistory();
@@ -103,4 +109,21 @@ export class HistoryComponent implements OnInit, OnChanges {
public showDetailedPunishment(entry: PunishmentHistory) {
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();
}
});
}
}
+15 -66
View File
@@ -23,7 +23,7 @@
<div class="columnContainer">
<div class="columnParagraph">
<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
enchantments.</p>
<p><span style="font-family: 'opensans-bold', sans-serif;">Please note:</span> Weeks reset on Saturday at
@@ -41,78 +41,27 @@
</section>
</section>
<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">
<h2>MinecraftServers</h2>
<h2>{{ voteSite }}</h2>
<div>
<a onclick="clickVote('vote1');" oncontextmenu="clickVote('vote1');" target="_blank" rel="noopener"
href="https://minecraftservers.org/vote/284208">
<div class="button-outer">
<span id="vote1" class="button-inner">Vote!</span>
<a (click)="clickVote(voteSite)" (contextmenu)="clickVote(voteSite); $event.preventDefault()"
target="_blank" rel="noopener"
[href]="voteSites[voteSite]">
<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>
</a>
</div>
@if (voteStats) {
<div class="voteStats">
<p>Last voted: {{ getLastVoted(voteSite) | TimeAgo: true }}</p>
</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>
</section>
<section class="darkmodeSection">
@@ -4,6 +4,11 @@
margin: 0 auto;
}
.voteContainer {
padding: 50px 0 0 0;
justify-content: center;
}
.voteSection {
background-color: var(--link-color);
transition: 0.5s ease;
@@ -36,3 +41,11 @@
color: black;
text-shadow: none;
}
.available-button-outer {
background-color: #4caf50 !important;
}
.not-available-button-outer {
background-color: var(--white) !important;
}
+84 -6
View File
@@ -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 {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({
selector: 'app-vote',
standalone: true,
imports: [
HeaderComponent
HeaderComponent,
TimeAgoPipe
],
templateUrl: './vote.component.html',
styleUrl: './vote.component.scss'
})
export class VoteComponent {
constructor(public scrollService: ScrollService) {
export class VoteComponent implements OnInit, OnDestroy {
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) {
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)
)
}
}
+55
View File
@@ -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
}
}
+14 -21
View File
@@ -1,23 +1,21 @@
import {Injectable, signal} from '@angular/core';
import {LoginService} from '@api';
import {BehaviorSubject, Observable, throwError} from 'rxjs';
import {Observable, throwError} from 'rxjs';
import {catchError, tap} from 'rxjs/operators';
import {MatSnackBar} from '@angular/material/snack-bar';
import {JwtHelperService} from '@auth0/angular-jwt';
import {JwtClaims} from '@custom-types/jwt_interface'
import {environment} from '@environment';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private isAuthenticatedSubject = new BehaviorSubject<boolean>(environment.defaultAuthStatus);
public isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
private isAuthenticatedSubject = signal<boolean>(false);
public readonly isAuthenticated$ = this.isAuthenticatedSubject.asReadonly();
private userClaimsSubject = new BehaviorSubject<JwtClaims | null>(null);
public userClaims$ = this.userClaimsSubject.asObservable();
private userClaimsSubject = signal<JwtClaims | null>(null);
private jwtHelper = new JwtHelperService();
private _username = signal<string | null>(environment.defaultAuthStatus ? 'akastijn' : null);
private _username = signal<string | null>(null);
public readonly username = this._username.asReadonly();
constructor(
@@ -35,7 +33,7 @@ export class AuthService {
return this.loginService.login(code).pipe(
tap(jwt => {
this.saveJwt(jwt);
this.isAuthenticatedSubject.next(true);
this.isAuthenticatedSubject.set(true);
this.reloadUsername();
}),
@@ -62,15 +60,15 @@ export class AuthService {
*/
public logout(): void {
localStorage.removeItem('jwt');
this.isAuthenticatedSubject.next(false);
this.userClaimsSubject.next(null);
this.isAuthenticatedSubject.set(false);
this.userClaimsSubject.set(null);
this._username.set(null);
}
/**
* Check if the user is authenticated
*/
public checkAuthStatus(): boolean {
private checkAuthStatus(): boolean {
const jwt = this.getJwt();
if (!jwt) {
console.log("No JWT found");
@@ -84,9 +82,8 @@ export class AuthService {
}
const claims = this.extractJwtClaims(jwt);
console.log("User claims: ", claims);
this.userClaimsSubject.next(claims);
this.isAuthenticatedSubject.next(true);
this.userClaimsSubject.set(claims);
this.isAuthenticatedSubject.set(true);
if (this.username() == null) {
this.reloadUsername();
}
@@ -111,8 +108,7 @@ export class AuthService {
localStorage.setItem('jwt', jwt);
const claims = this.extractJwtClaims(jwt);
console.log("Saving user claims: ", claims);
this.userClaimsSubject.next(claims);
this.userClaimsSubject.set(claims);
}
/**
@@ -126,7 +122,7 @@ export class AuthService {
* Get user authorizations from claims
*/
public getUserAuthorizations(): string[] {
const claims = this.userClaimsSubject.getValue();
const claims = this.userClaimsSubject();
return claims?.authorities || [];
}
@@ -136,10 +132,7 @@ export class AuthService {
}
public getUuid(): string | null {
if (environment.defaultAuthStatus) {
return '55e46bc3-2a29-4c53-850f-dbd944dc5c5f';
}
const jwtClaims = this.userClaimsSubject.getValue();
const jwtClaims = this.userClaimsSubject();
if (jwtClaims === null) {
return null;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export const ALTITUDE_VERSION = "1.21"
export const ALTITUDE_VERSION = "1.21.8"
export const enum THEME_MODE {
LIGHT = 'theme-light',
@@ -1,5 +1,3 @@
export const environment = {
production: true,
apiUrl: 'https://beta.alttd.com',
defaultAuthStatus: false
};
@@ -1,5 +1,3 @@
export const environment = {
production: false,
apiUrl: 'http://localhost:8080',
defaultAuthStatus: true
};
@@ -1,5 +1,3 @@
export const environment = {
production: true,
apiUrl: 'https://alttd.com',
defaultAuthStatus: false
};
-2
View File
@@ -1,5 +1,3 @@
export const environment = {
production: true,
apiUrl: 'https://alttd.com',
defaultAuthStatus: false
};
+2 -3
View File
@@ -3,18 +3,17 @@ import {AppComponent} from './app/app.component';
import {provideRouter} from '@angular/router';
import {routes} from './app/app.routes';
import {provideHttpClient, withInterceptors} from '@angular/common/http';
import {BASE_PATH} from '@api';
import {environment} from '@environment';
import {authInterceptor} from '@services/AuthInterceptor';
import {provideNativeDateAdapter} from '@angular/material/core';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(),
{provide: BASE_PATH, useValue: environment.apiUrl},
provideHttpClient(
withInterceptors([authInterceptor])
),
provideNativeDateAdapter()
]
}).catch(err => console.error(err));
+9
View File
@@ -350,6 +350,15 @@ main .container {
background: #8b8a8f;
}
.checkbox-field mat-checkbox {
color: var(--font-color);
}
.mat-mdc-checkbox .mdc-form-field label {
color: var(--font-color);
}
// Darkmode sections
.darkmodeSection {
background-color: var(--color-secondary);
+3 -3
View File
@@ -43,9 +43,9 @@ sourceSets {
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("io.swagger.core.v3:swagger-annotations:2.2.20")
implementation("io.swagger.core.v3:swagger-models:2.2.8")
implementation("io.swagger.core.v3:swagger-core:2.2.8")
implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
implementation("io.swagger.core.v3:swagger-models:2.2.37")
implementation("io.swagger.core.v3:swagger-core:2.2.37")
implementation("org.openapitools:jackson-databind-nullable:0.2.6")
implementation("org.springframework.hateoas:spring-hateoas:2.2.0")
+26
View File
@@ -26,6 +26,10 @@ tags:
description: All actions shared between forms
- name: appeals
description: All action related to appeals
- name: mail
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:
/api/team/{team}:
$ref: './schemas/team/team.yml#/getTeam'
@@ -47,12 +51,22 @@ paths:
$ref: './schemas/bans/bans.yml#/getAllHistoryForUUID'
/api/history/total:
$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:
$ref: './schemas/forms/appeal/appeal.yml#/UpdateMail'
/api/appeal/minecraft-appeal:
$ref: './schemas/forms/appeal/appeal.yml#/MinecraftAppeal'
/api/appeal/discord-appeal:
$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}:
$ref: './schemas/login/login.yml#/RequestNewUserLogin'
/api/login/userLogin/{code}:
@@ -67,3 +81,15 @@ paths:
$ref: './schemas/particles/particles.yml#/DownloadFile'
/api/files/download/{uuid}/{filename}:
$ref: './schemas/particles/particles.yml#/DownloadFileForUser'
/api/mail/submit:
$ref: './schemas/forms/mail/mail.yml#/SubmitEmail'
/api/mail/verify:
$ref: './schemas/forms/mail/mail.yml#/VerifyCode'
/api/mail/resend:
$ref: './schemas/forms/mail/mail.yml#/ResendEmail'
/api/mail/delete:
$ref: './schemas/forms/mail/mail.yml#/DeleteEmail'
/api/mail/list:
$ref: './schemas/forms/mail/mail.yml#/GetEmails'
/api/site/vote:
$ref: './schemas/site/vote.yml#/VoteStats'
@@ -211,6 +211,73 @@ getAllHistoryForUUID:
application/json:
schema:
$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:
parameters:
HistoryType:
@@ -250,6 +317,21 @@ components:
schema:
type: integer
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:
SearchResults:
type: integer
@@ -23,7 +23,7 @@ UpdateMail:
content:
application/json:
schema:
$ref: '#/components/schemas/AppealResponse'
$ref: '../../generic/components.yml#/components/schemas/FormResponse'
default:
description: Unexpected error
content:
@@ -49,7 +49,7 @@ MinecraftAppeal:
content:
application/json:
schema:
$ref: '#/components/schemas/AppealResponse'
$ref: '../../generic/components.yml#/components/schemas/FormResponse'
default:
description: Unexpected error
content:
@@ -75,7 +75,7 @@ DiscordAppeal:
content:
application/json:
schema:
$ref: '#/components/schemas/MinecraftAppeal'
$ref: '../../generic/components.yml#/components/schemas/FormResponse'
default:
description: Unexpected error
content:
@@ -92,6 +92,7 @@ components:
- username
- email
- punishmentId
- punishmentType
- appeal
properties:
username:
@@ -107,6 +108,10 @@ components:
punishmentId:
type: integer
description: Unique identifier of the punishment being appealed
punishmentType:
type: string
enum: [ ban, mute, kick, warn ]
description: Unique identifier of the punishment being appealed
appeal:
type: string
description: Appeal text explaining why the punishment should be reconsidered
@@ -114,15 +119,10 @@ components:
type: object
description: Schema for Discord ban/punishment appeals
required:
- username
- userId
- email
- punishmentId
- appeal
properties:
username:
type: string
description: Discord username of the appealing user
userId:
type: integer
format: int64
@@ -136,18 +136,6 @@ components:
appeal:
type: string
description: Appeal text explaining why the punishment should be reconsidered
AppealResponse:
type: object
required:
- id
- message
properties:
id:
type: string
description: Unique identifier for the submitted appeal for referring to it later
message:
type: string
description: Confirmation message
UpdateMail:
type: object
required:
@@ -0,0 +1,166 @@
SubmitEmail:
post:
tags:
- mail
summary: Submit an email for verification
description: Store a new email for the authenticated user and send a verification code
operationId: submitEmailForVerification
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitEmail'
responses:
'201':
description: Verification email sent
content:
application/json:
schema:
$ref: '#/components/schemas/MailResponse'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
VerifyCode:
post:
tags:
- mail
summary: Verify an email using a code
description: Verify the email for the authenticated user by providing the received code
operationId: verifyEmailCode
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VerifyCode'
responses:
'200':
description: Email verified successfully
content:
application/json:
schema:
$ref: '#/components/schemas/MailResponse'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
ResendEmail:
post:
tags:
- mail
summary: Resend verification email
description: Request a new verification email to be sent for a pending email
operationId: resendVerificationEmail
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitEmail'
responses:
'200':
description: Verification email resent
content:
application/json:
schema:
$ref: '#/components/schemas/MailResponse'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
DeleteEmail:
delete:
tags:
- mail
summary: Delete an email
description: Delete an email associated with the authenticated user
operationId: deleteEmail
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitEmail'
responses:
'204':
description: Email deleted
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
GetEmails:
get:
tags:
- mail
summary: Get all emails for the authenticated user
description: Returns both verified and unverified emails for the authenticated user
operationId: getUserEmails
responses:
'200':
description: Emails retrieved successfully
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/EmailEntry'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
components:
schemas:
SubmitEmail:
type: object
required:
- email
properties:
email:
type: string
description: Email address to verify
VerifyCode:
type: object
required:
- code
properties:
code:
type: string
description: Verification code received by email
MailResponse:
type: object
required:
- message
- email
- verified
properties:
email:
type: string
message:
type: string
verified:
type: boolean
EmailEntry:
type: object
required:
- email
- verified
properties:
email:
type: string
description: The user's email address
verified:
type: boolean
description: Whether the email has been verified

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