Compare commits
No commits in common. "8bfcdb6cccae8e015c603e2b4a2e114f8d5019b3" and "b71ea7da8b3d602b50dd721cc6f778a26173d1f0" have entirely different histories.
8bfcdb6ccc
...
b71ea7da8b
|
|
@ -9,7 +9,6 @@ import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||||
import com.nimbusds.jose.proc.SecurityContext;
|
import com.nimbusds.jose.proc.SecurityContext;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
|
|
@ -18,22 +17,16 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.core.GrantedAuthority;
|
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
|
||||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.interfaces.RSAPrivateKey;
|
import java.security.interfaces.RSAPrivateKey;
|
||||||
import java.security.interfaces.RSAPublicKey;
|
import java.security.interfaces.RSAPublicKey;
|
||||||
import java.util.*;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
|
@ -54,14 +47,13 @@ public class SecurityConfig {
|
||||||
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
|
||||||
.requestMatchers("/api/login/userLogin/**").permitAll()
|
.requestMatchers("/api/login/userLogin/**").permitAll()
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
.oauth2ResourceServer(
|
.oauth2ResourceServer(
|
||||||
oauth2 -> oauth2
|
oauth2 -> oauth2
|
||||||
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
|
.jwt(Customizer.withDefaults())
|
||||||
.authenticationEntryPoint(securityAuthFailureHandler)
|
.authenticationEntryPoint(securityAuthFailureHandler)
|
||||||
.accessDeniedHandler(securityAuthFailureHandler)
|
.accessDeniedHandler(securityAuthFailureHandler)
|
||||||
)
|
)
|
||||||
|
|
@ -92,46 +84,4 @@ public class SecurityConfig {
|
||||||
KeyPair keyPair = keyPairService.getJwtSigningKeyPair();
|
KeyPair keyPair = keyPairService.getJwtSigningKeyPair();
|
||||||
return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build();
|
return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public JwtAuthenticationConverter jwtAuthenticationConverter() {
|
|
||||||
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
|
||||||
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
|
|
||||||
Map<String, Object> claims = jwt.getClaims();
|
|
||||||
|
|
||||||
Object authoritiesClaim = claims.get("authorities");
|
|
||||||
if (authoritiesClaim instanceof List<?> authorities) {
|
|
||||||
Collection<GrantedAuthority> authorityList = authorities.stream()
|
|
||||||
.map(Object::toString)
|
|
||||||
.map(SimpleGrantedAuthority::new)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
log.debug("Authorities found in authorities: {}", authorityList);
|
|
||||||
return authorityList;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object scopeClaim = claims.get("scope");
|
|
||||||
if (scopeClaim instanceof String scopeString) {
|
|
||||||
Collection<GrantedAuthority> authorityList = Arrays.stream(scopeString.split(" "))
|
|
||||||
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
log.debug("Authorities found in authorities scope string: {}", authorityList);
|
|
||||||
return authorityList;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scopeClaim instanceof List<?> scopeList) {
|
|
||||||
Collection<GrantedAuthority> authorityList = scopeList.stream()
|
|
||||||
.map(Object::toString)
|
|
||||||
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
log.debug("Authorities found in authorities scope list: {}", authorityList);
|
|
||||||
return authorityList;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("No granted authorities found");
|
|
||||||
return Collections.emptyList();
|
|
||||||
});
|
|
||||||
return converter;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ public class CorsConfig implements WebMvcConfigurer {
|
||||||
log.info("Registering CORS mappings for {}", String.join(", ", allowedOrigins));
|
log.info("Registering CORS mappings for {}", String.join(", ", allowedOrigins));
|
||||||
registry.addMapping("/**")
|
registry.addMapping("/**")
|
||||||
.allowedOrigins(allowedOrigins)
|
.allowedOrigins(allowedOrigins)
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||||
.allowedHeaders("*")
|
.allowedHeaders("*")
|
||||||
.allowCredentials(true);
|
.allowCredentials(true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,24 @@
|
||||||
package com.alttd.altitudeweb.controllers.data_from_auth;
|
package com.alttd.altitudeweb.controllers.data_from_auth;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
|
||||||
public class AuthenticatedUuid {
|
public class AuthenticatedUuid {
|
||||||
@Value("${UNSECURED:#{false}}")
|
|
||||||
private boolean unsecured;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts and validates the authenticated user's UUID from the JWT token.
|
* Extracts and validates the authenticated user's UUID from the JWT token.
|
||||||
*
|
*
|
||||||
* @return The UUID of the authenticated user
|
* @return The UUID of the authenticated user
|
||||||
* @throws ResponseStatusException with 401 status if authentication is invalid
|
* @throws ResponseStatusException with 401 status if authentication is invalid
|
||||||
*/
|
*/
|
||||||
public UUID getAuthenticatedUserUuid() {
|
public static UUID getAuthenticatedUserUuid() {
|
||||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
|
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
|
||||||
if (unsecured) {
|
|
||||||
return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f");
|
|
||||||
}
|
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import java.util.concurrent.TimeUnit;
|
||||||
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
|
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
|
||||||
public class ApplicationController implements ApplicationsApi {
|
public class ApplicationController implements ApplicationsApi {
|
||||||
|
|
||||||
private final AuthenticatedUuid authenticatedUuid;
|
|
||||||
private final StaffApplicationDataMapper staffApplicationDataMapper;
|
private final StaffApplicationDataMapper staffApplicationDataMapper;
|
||||||
private final StaffApplicationMail staffApplicationMail;
|
private final StaffApplicationMail staffApplicationMail;
|
||||||
private final StaffApplicationDiscord staffApplicationDiscord;
|
private final StaffApplicationDiscord staffApplicationDiscord;
|
||||||
|
|
@ -56,7 +55,7 @@ public class ApplicationController implements ApplicationsApi {
|
||||||
if (!isOpen()) {
|
if (!isOpen()) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||||
}
|
}
|
||||||
UUID userUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
UUID userUuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||||
|
|
||||||
String email = staffApplicationDto.getEmail() == null ? null : staffApplicationDto.getEmail().toLowerCase();
|
String email = staffApplicationDto.getEmail() == null ? null : staffApplicationDto.getEmail().toLowerCase();
|
||||||
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, email);
|
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, email);
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,11 @@ import java.util.concurrent.TimeUnit;
|
||||||
public class MailController implements MailApi {
|
public class MailController implements MailApi {
|
||||||
|
|
||||||
private final MailVerificationService mailVerificationService;
|
private final MailVerificationService mailVerificationService;
|
||||||
private final AuthenticatedUuid authenticatedUuid;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit")
|
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit")
|
||||||
public ResponseEntity<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) {
|
public ResponseEntity<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) {
|
||||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||||
boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream()
|
boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream()
|
||||||
.filter(EmailVerification::verified)
|
.filter(EmailVerification::verified)
|
||||||
.map(EmailVerification::email)
|
.map(EmailVerification::email)
|
||||||
|
|
@ -53,7 +52,7 @@ public class MailController implements MailApi {
|
||||||
@Override
|
@Override
|
||||||
@RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify")
|
@RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify")
|
||||||
public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
|
public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
|
||||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||||
Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode());
|
Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode());
|
||||||
if (optionalEmailVerification.isEmpty()) {
|
if (optionalEmailVerification.isEmpty()) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code");
|
||||||
|
|
@ -69,7 +68,7 @@ public class MailController implements MailApi {
|
||||||
@Override
|
@Override
|
||||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend")
|
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend")
|
||||||
public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) {
|
public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) {
|
||||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||||
EmailVerification updated = mailVerificationService.resend(uuid, submitEmailDto.getEmail());
|
EmailVerification updated = mailVerificationService.resend(uuid, submitEmailDto.getEmail());
|
||||||
if (updated == null) {
|
if (updated == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
|
||||||
|
|
@ -84,7 +83,7 @@ public class MailController implements MailApi {
|
||||||
@Override
|
@Override
|
||||||
@RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete")
|
@RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete")
|
||||||
public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) {
|
public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) {
|
||||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||||
boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail());
|
boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail());
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
|
||||||
|
|
@ -94,7 +93,7 @@ public class MailController implements MailApi {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<List<EmailEntryDto>> getUserEmails() {
|
public ResponseEntity<List<EmailEntryDto>> getUserEmails() {
|
||||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||||
List<EmailVerification> emails = mailVerificationService.listAll(uuid);
|
List<EmailVerification> emails = mailVerificationService.listAll(uuid);
|
||||||
List<EmailEntryDto> result = emails.stream()
|
List<EmailEntryDto> result = emails.stream()
|
||||||
.map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified()))
|
.map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified()))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package com.alttd.altitudeweb.controllers.history;
|
package com.alttd.altitudeweb.controllers.history;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.api.HistoryApi;
|
import com.alttd.altitudeweb.api.HistoryApi;
|
||||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
|
||||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||||
import com.alttd.altitudeweb.model.HistoryCountDto;
|
import com.alttd.altitudeweb.model.HistoryCountDto;
|
||||||
import com.alttd.altitudeweb.model.PunishmentHistoryListDto;
|
import com.alttd.altitudeweb.model.PunishmentHistoryListDto;
|
||||||
|
|
@ -9,11 +8,9 @@ import com.alttd.altitudeweb.setup.Connection;
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
import com.alttd.altitudeweb.database.Databases;
|
||||||
import com.alttd.altitudeweb.database.litebans.*;
|
import com.alttd.altitudeweb.database.litebans.*;
|
||||||
import com.alttd.altitudeweb.model.PunishmentHistoryDto;
|
import com.alttd.altitudeweb.model.PunishmentHistoryDto;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
@ -23,11 +20,8 @@ import java.util.concurrent.TimeUnit;
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RateLimit(limit = 30, timeValue = 10, timeUnit = TimeUnit.SECONDS)
|
@RateLimit(limit = 30, timeValue = 10, timeUnit = TimeUnit.SECONDS)
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class HistoryApiController implements HistoryApi {
|
public class HistoryApiController implements HistoryApi {
|
||||||
|
|
||||||
private final AuthenticatedUuid authenticatedUuid;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<PunishmentHistoryListDto> getHistoryForAll(String userType, String type, Integer page) {
|
public ResponseEntity<PunishmentHistoryListDto> getHistoryForAll(String userType, String type, Integer page) {
|
||||||
return getHistoryForUsers(userType, type, "", page);
|
return getHistoryForUsers(userType, type, "", page);
|
||||||
|
|
@ -235,108 +229,4 @@ public class HistoryApiController implements HistoryApi {
|
||||||
.type(type)
|
.type(type)
|
||||||
.id(historyRecord.getId());
|
.id(historyRecord.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public ResponseEntity<PunishmentHistoryDto> updatePunishmentReason(String type, Integer id, String reason) {
|
|
||||||
log.debug("Updating reason for {} id {} to {}", type, id, reason);
|
|
||||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
|
||||||
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
|
||||||
|
|
||||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
|
||||||
try {
|
|
||||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
|
||||||
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
|
|
||||||
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
|
|
||||||
if (before == null) {
|
|
||||||
result.complete(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
int changed = editMapper.setReason(historyTypeEnum, id, reason);
|
|
||||||
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
|
||||||
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
|
||||||
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
|
|
||||||
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
|
|
||||||
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to update reason for {} id {}", type, id, e);
|
|
||||||
result.completeExceptionally(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
PunishmentHistoryDto body = result.join();
|
|
||||||
if (body == null) {
|
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
}
|
|
||||||
return ResponseEntity.ok(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ResponseEntity<PunishmentHistoryDto> updatePunishmentUntil(String type, Integer id, Long until) {
|
|
||||||
log.debug("Updating until for {} id {} to {}", type, id, until);
|
|
||||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
|
||||||
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
|
|
||||||
|
|
||||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
|
||||||
try {
|
|
||||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
|
||||||
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
|
|
||||||
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
|
|
||||||
if (before == null) {
|
|
||||||
result.complete(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
int changed = editMapper.setUntil(historyTypeEnum, id, until);
|
|
||||||
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
|
|
||||||
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
|
|
||||||
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
|
|
||||||
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
|
|
||||||
result.complete(after != null ? mapPunishmentHistory(after) : null);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
log.warn("Invalid until edit for type {} id {}: {}", type, id, e.getMessage());
|
|
||||||
result.complete(null);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to update until for {} id {}", type, id, e);
|
|
||||||
result.completeExceptionally(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
PunishmentHistoryDto body = result.join();
|
|
||||||
if (body == null) {
|
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
}
|
|
||||||
return ResponseEntity.ok(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ResponseEntity<Void> removePunishment(String type, Integer id) {
|
|
||||||
log.debug("Removing punishment for {} id {}", type, id);
|
|
||||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
|
||||||
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
|
||||||
|
|
||||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
|
|
||||||
try {
|
|
||||||
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
|
|
||||||
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
|
|
||||||
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
|
|
||||||
if (before == null) {
|
|
||||||
result.complete(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
|
|
||||||
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
|
|
||||||
int changed = editMapper.remove(historyTypeEnum, id);
|
|
||||||
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",
|
|
||||||
actorName, actorUuid, historyTypeEnum, id,
|
|
||||||
before.getRemovedByName() == null ? 1 : 0, before.getRemovedByName(), before.getRemovedByReason(),
|
|
||||||
changed);
|
|
||||||
result.complete(changed > 0);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to remove punishment for {} id {}", type, id, e);
|
|
||||||
result.completeExceptionally(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Boolean ok = result.join();
|
|
||||||
if (ok == null || !ok) {
|
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
}
|
|
||||||
return ResponseEntity.noContent().build();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ import java.util.concurrent.TimeUnit;
|
||||||
public class LoginController implements LoginApi {
|
public class LoginController implements LoginApi {
|
||||||
|
|
||||||
private final JwtEncoder jwtEncoder;
|
private final JwtEncoder jwtEncoder;
|
||||||
private final AuthenticatedUuid authenticatedUuid;
|
|
||||||
|
|
||||||
@Value("${login.secret:#{null}}")
|
@Value("${login.secret:#{null}}")
|
||||||
private String loginSecret;
|
private String loginSecret;
|
||||||
|
|
@ -100,7 +99,7 @@ public class LoginController implements LoginApi {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get authenticated UUID using the utility method
|
// Get authenticated UUID using the utility method
|
||||||
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
|
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||||
log.debug("Loaded username for logged in user {}", uuid);
|
log.debug("Loaded username for logged in user {}", uuid);
|
||||||
|
|
||||||
// Create response with username
|
// Create response with username
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
package com.alttd.altitudeweb.services.site;
|
|
||||||
|
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
|
||||||
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper;
|
|
||||||
import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow;
|
|
||||||
import com.alttd.altitudeweb.mappers.VotingStatsRowToVoteDataDto;
|
|
||||||
import com.alttd.altitudeweb.model.VoteDataDto;
|
|
||||||
import com.alttd.altitudeweb.setup.Connection;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class VoteService {
|
|
||||||
|
|
||||||
private final VotingStatsRowToVoteDataDto votingStatsRowToVoteDataDto;
|
|
||||||
|
|
||||||
public Optional<VoteDataDto> getVoteStats(UUID uuid) {
|
|
||||||
CompletableFuture<Optional<VoteDataDto>> voteDataDtoFuture = new CompletableFuture<>();
|
|
||||||
Connection.getConnection(Databases.VOTING_PLUGIN).runQuery(sqlSession -> {
|
|
||||||
try {
|
|
||||||
VotingPluginUsersMapper votingPluginUsersMapper = sqlSession.getMapper(VotingPluginUsersMapper.class);
|
|
||||||
Optional<VotingStatsRow> optionalVotingStatsRow = votingPluginUsersMapper.getStatsByUuid(uuid);
|
|
||||||
if (optionalVotingStatsRow.isEmpty()) {
|
|
||||||
log.debug("No voting stats found for {}", uuid);
|
|
||||||
voteDataDtoFuture.complete(Optional.empty());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
VotingStatsRow votingStatsRow = optionalVotingStatsRow.get();
|
|
||||||
|
|
||||||
VoteDataDto voteDataDto = votingStatsRowToVoteDataDto.map(votingStatsRow);
|
|
||||||
voteDataDtoFuture.complete(Optional.of(voteDataDto));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to get vote data for {}", uuid, e);
|
|
||||||
voteDataDtoFuture.completeExceptionally(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return voteDataDtoFuture.join();//TODO handle exception
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080,http://localhost:80}
|
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080}
|
||||||
my-server.address=${SERVER_ADDRESS:http://localhost:8080}
|
my-server.address=${SERVER_ADDRESS:http://localhost:8080}
|
||||||
logging.level.com.alttd.altitudeweb=DEBUG
|
logging.level.com.alttd.altitudeweb=DEBUG
|
||||||
logging.level.org.springframework.security=DEBUG
|
logging.level.org.springframework.security=DEBUG
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ public enum Databases {
|
||||||
DEFAULT("web_db"),
|
DEFAULT("web_db"),
|
||||||
LUCK_PERMS("luckperms"),
|
LUCK_PERMS("luckperms"),
|
||||||
LITE_BANS("litebans"),
|
LITE_BANS("litebans"),
|
||||||
DISCORD("discordLink"),
|
DISCORD("discordLink");
|
||||||
VOTING_PLUGIN("votingplugin");
|
|
||||||
|
|
||||||
private final String internalName;
|
private final String internalName;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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
|
|
||||||
) { }
|
|
||||||
|
|
@ -38,7 +38,6 @@ public class Connection {
|
||||||
InitializeLiteBans.init();
|
InitializeLiteBans.init();
|
||||||
InitializeLuckPerms.init();
|
InitializeLuckPerms.init();
|
||||||
InitializeDiscord.init();
|
InitializeDiscord.init();
|
||||||
InitializeVotingPlugin.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ public class InitializeLiteBans {
|
||||||
configuration.addMapper(UUIDHistoryMapper.class);
|
configuration.addMapper(UUIDHistoryMapper.class);
|
||||||
configuration.addMapper(HistoryCountMapper.class);
|
configuration.addMapper(HistoryCountMapper.class);
|
||||||
configuration.addMapper(IdHistoryMapper.class);
|
configuration.addMapper(IdHistoryMapper.class);
|
||||||
configuration.addMapper(EditHistoryMapper.class);
|
|
||||||
}).join()
|
}).join()
|
||||||
.runQuery(sqlSession -> {
|
.runQuery(sqlSession -> {
|
||||||
createAllPunishmentsView(sqlSession);
|
createAllPunishmentsView(sqlSession);
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -21,7 +21,7 @@ export class AuthGuard implements CanActivate {
|
||||||
route: ActivatedRouteSnapshot,
|
route: ActivatedRouteSnapshot,
|
||||||
state: RouterStateSnapshot
|
state: RouterStateSnapshot
|
||||||
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
||||||
if (!this.authService.isAuthenticated$()) {
|
if (!this.authService.checkAuthStatus()) {
|
||||||
this.router.createUrlTree(['/']);
|
this.router.createUrlTree(['/']);
|
||||||
const dialogRef = this.dialog.open(LoginDialogComponent, {
|
const dialogRef = this.dialog.open(LoginDialogComponent, {
|
||||||
width: '400px',
|
width: '400px',
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
import {Component, Inject, inject, signal} from '@angular/core';
|
|
||||||
import {FormsModule} from '@angular/forms';
|
|
||||||
import {MatButtonModule} from '@angular/material/button';
|
|
||||||
import {MatFormFieldModule} from '@angular/material/form-field';
|
|
||||||
import {MatInput, MatLabel} from '@angular/material/input';
|
|
||||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
|
||||||
import {
|
|
||||||
MAT_DIALOG_DATA,
|
|
||||||
MatDialogActions,
|
|
||||||
MatDialogContent,
|
|
||||||
MatDialogRef,
|
|
||||||
MatDialogTitle
|
|
||||||
} from '@angular/material/dialog';
|
|
||||||
import {HistoryService, PunishmentHistory} from '@api';
|
|
||||||
import {firstValueFrom} from 'rxjs';
|
|
||||||
import {MatDatepickerModule} from '@angular/material/datepicker';
|
|
||||||
|
|
||||||
interface EditPunishmentData {
|
|
||||||
punishment: PunishmentHistory;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-edit-punishment-dialog',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
FormsModule,
|
|
||||||
MatButtonModule,
|
|
||||||
MatFormFieldModule,
|
|
||||||
MatInput,
|
|
||||||
MatLabel,
|
|
||||||
MatCheckboxModule,
|
|
||||||
MatDialogTitle,
|
|
||||||
MatDialogContent,
|
|
||||||
MatDialogActions,
|
|
||||||
MatDatepickerModule,
|
|
||||||
],
|
|
||||||
templateUrl: './edit-punishment-dialog.component.html',
|
|
||||||
styleUrl: './edit-punishment-dialog.component.scss'
|
|
||||||
})
|
|
||||||
export class EditPunishmentDialogComponent {
|
|
||||||
// Form model
|
|
||||||
protected reason: string = '';
|
|
||||||
protected isPermanent: boolean = false;
|
|
||||||
protected expiresAt: Date;
|
|
||||||
protected expiryDate: Date | null = null;
|
|
||||||
protected expiryTime: string = '';
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
protected isBusy = signal<boolean>(false);
|
|
||||||
protected errorMessage = signal<string | null>(null);
|
|
||||||
|
|
||||||
private historyApi = inject(HistoryService);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public dialogRef: MatDialogRef<EditPunishmentDialogComponent, PunishmentHistory | { removed: true } | null>,
|
|
||||||
@Inject(MAT_DIALOG_DATA) public data: EditPunishmentData
|
|
||||||
) {
|
|
||||||
const punishment = data.punishment;
|
|
||||||
this.reason = punishment.reason ?? '';
|
|
||||||
this.isPermanent = punishment.expiryTime <= 0;
|
|
||||||
this.expiresAt = new Date(punishment.expiryTime);
|
|
||||||
|
|
||||||
if (this.expiresAt && !isNaN(this.expiresAt.getTime())) {
|
|
||||||
this.expiryDate = new Date(this.expiresAt);
|
|
||||||
|
|
||||||
const hours = this.expiresAt.getHours().toString().padStart(2, '0');
|
|
||||||
const minutes = this.expiresAt.getMinutes().toString().padStart(2, '0');
|
|
||||||
this.expiryTime = `${hours}:${minutes}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onCancel(): void {
|
|
||||||
if (this.isBusy()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.dialogRef.close(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private computeUntilMs(): number {
|
|
||||||
if (this.isPermanent) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.expiryDate) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const combinedDateTime = new Date(this.expiryDate);
|
|
||||||
|
|
||||||
if (this.expiryTime) {
|
|
||||||
const [hours, minutes] = this.expiryTime.split(':').map(Number);
|
|
||||||
combinedDateTime.setHours(hours, minutes, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.expiresAt = combinedDateTime;
|
|
||||||
|
|
||||||
const ms = combinedDateTime.getTime();
|
|
||||||
return isNaN(ms) ? -1 : ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdate(): void {
|
|
||||||
const punishment = this.data.punishment;
|
|
||||||
if (!window.confirm('Are you sure you want to update this punishment?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.isBusy.set(true);
|
|
||||||
this.errorMessage.set(null);
|
|
||||||
|
|
||||||
const updates: Array<Promise<PunishmentHistory>> = [] as any;
|
|
||||||
|
|
||||||
if ((this.reason ?? '') !== (punishment.reason ?? '')) {
|
|
||||||
console.log('Changing reason to ', this.reason, ' from ', punishment.reason, '')
|
|
||||||
updates.push(firstValueFrom(this.historyApi.updatePunishmentReason(punishment.type, punishment.id, this.reason)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const newUntil = this.computeUntilMs();
|
|
||||||
if (newUntil !== punishment.expiryTime) {
|
|
||||||
console.log('Changing until to ', newUntil, ' from ', punishment.expiryTime, '')
|
|
||||||
updates.push(firstValueFrom(this.historyApi.updatePunishmentUntil(punishment.type, punishment.id, newUntil)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.length === 0) {
|
|
||||||
this.isBusy.set(false);
|
|
||||||
this.dialogRef.close(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise.all(updates)
|
|
||||||
.then(results => {
|
|
||||||
const updated = results[results.length - 1];
|
|
||||||
this.isBusy.set(false);
|
|
||||||
this.dialogRef.close(updated);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
this.errorMessage.set('Failed to update punishment');
|
|
||||||
this.isBusy.set(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onRemove(): void {
|
|
||||||
const punishment = this.data.punishment;
|
|
||||||
if (!window.confirm('Are you sure you want to remove this punishment?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.isBusy.set(true);
|
|
||||||
this.errorMessage.set(null);
|
|
||||||
|
|
||||||
this.historyApi.removePunishment(punishment.type as any, punishment.id).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.isBusy.set(false);
|
|
||||||
this.dialogRef.close({removed: true});
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
console.error(err);
|
|
||||||
this.errorMessage.set('Failed to remove punishment');
|
|
||||||
this.isBusy.set(false);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getTimezone() {
|
|
||||||
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -60,7 +60,7 @@ export class HistoryFormatService {
|
||||||
|
|
||||||
public getAvatarUrl(entry: string, size: string = '25'): string {
|
public getAvatarUrl(entry: string, size: string = '25'): string {
|
||||||
let uuid = entry.replace('-', '');
|
let uuid = entry.replace('-', '');
|
||||||
if (uuid === 'C' || uuid === 'Console' || uuid === '[Console]') {
|
if (uuid === 'C') {
|
||||||
uuid = "f78a4d8dd51b4b3998a3230f2de0c670"
|
uuid = "f78a4d8dd51b4b3998a3230f2de0c670"
|
||||||
}
|
}
|
||||||
return `https://crafatar.com/avatars/${uuid}?size=${size}&overlay`;
|
return `https://crafatar.com/avatars/${uuid}?size=${size}&overlay`;
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,14 @@
|
||||||
<table [cellSpacing]="0">
|
<table [cellSpacing]="0">
|
||||||
<div class="historyTableHead">
|
<div class="historyTableHead">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="historyType">Type</th>
|
<th class="historyType">Type</th>
|
||||||
<th class="historyPlayer">Player</th>
|
<th class="historyPlayer">Player</th>
|
||||||
<th class="historyPlayer">Banned By</th>
|
<th class="historyPlayer">Banned By</th>
|
||||||
<th class="historyReason">Reason</th>
|
<th class="historyReason">Reason</th>
|
||||||
<th class="historyDate">Date</th>
|
<th class="historyDate">Date</th>
|
||||||
<th class="historyDate">Expires</th>
|
<th class="historyDate">Expires</th>
|
||||||
@if (canEdit()) {
|
</tr>
|
||||||
<th class="historyActions"></th>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
</thead>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -29,36 +26,30 @@
|
||||||
<td class="historyPlayer" (click)="setSearch(entry.username, 'player')">
|
<td class="historyPlayer" (click)="setSearch(entry.username, 'player')">
|
||||||
<div class="playerContainer">
|
<div class="playerContainer">
|
||||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.uuid)" width="25" height="25"
|
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.uuid)" width="25" height="25"
|
||||||
alt="{{entry.username}}'s Minecraft skin">
|
alt="{{entry.username}}'s Minecraft skin">
|
||||||
<span class="username">{{ entry.username }}</span>
|
<span class="username">{{ entry.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
|
|
||||||
<div class="playerContainer">
|
|
||||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25"
|
|
||||||
height="25"
|
|
||||||
alt="{{entry.punishedBy}}'s Minecraft skin">
|
|
||||||
<span>{{ entry.punishedBy }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="historyReason" (click)="showDetailedPunishment(entry)">
|
|
||||||
{{ entry.reason | removeTrailingPeriod }}
|
|
||||||
</td>
|
|
||||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
|
||||||
{{ this.historyFormat.getPunishmentTime(entry) }}
|
|
||||||
</td>
|
|
||||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
|
||||||
{{ this.historyFormat.getExpiredTime(entry) }}
|
|
||||||
</td>
|
|
||||||
@if (canEdit()) {
|
|
||||||
<td class="historyActions" (click)="openEdit(entry)">
|
|
||||||
<mat-icon>edit</mat-icon>
|
|
||||||
</td>
|
</td>
|
||||||
|
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
|
||||||
|
<div class="playerContainer">
|
||||||
|
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25" height="25"
|
||||||
|
alt="{{entry.punishedBy}}'s Minecraft skin">
|
||||||
|
<span>{{ entry.punishedBy }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="historyReason" (click)="showDetailedPunishment(entry)">
|
||||||
|
{{ entry.reason | removeTrailingPeriod }}
|
||||||
|
</td>
|
||||||
|
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||||
|
{{ this.historyFormat.getPunishmentTime(entry) }}
|
||||||
|
</td>
|
||||||
|
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||||
|
{{ this.historyFormat.getExpiredTime(entry) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
</tr>
|
</tbody>
|
||||||
}
|
</div>
|
||||||
</tbody>
|
</table>
|
||||||
</div>
|
}
|
||||||
</table>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,3 @@ img {
|
||||||
.historyDate {
|
.historyDate {
|
||||||
width: 170px;
|
width: 170px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.historyActions {
|
|
||||||
width: 100px;
|
|
||||||
padding: 0 10px 0 10px;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import {Component, EventEmitter, inject, Input, OnChanges, OnInit, Output} from '@angular/core';
|
import {Component, EventEmitter, Input, OnChanges, OnInit, Output} from '@angular/core';
|
||||||
import {HistoryService, PunishmentHistory} from '@api';
|
import {HistoryService, PunishmentHistory} from '@api';
|
||||||
import {catchError, map, Observable, shareReplay} from 'rxjs';
|
import {catchError, map, Observable, shareReplay} from 'rxjs';
|
||||||
import {NgOptimizedImage} from '@angular/common';
|
import {NgOptimizedImage} from '@angular/common';
|
||||||
|
|
@ -8,17 +8,12 @@ import {HttpErrorResponse} from '@angular/common/http';
|
||||||
import {HistoryFormatService} from '../history-format.service';
|
import {HistoryFormatService} from '../history-format.service';
|
||||||
import {SearchParams} from '../search-terms';
|
import {SearchParams} from '../search-terms';
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {AuthService} from '@services/auth.service';
|
|
||||||
import {MatDialog} from '@angular/material/dialog';
|
|
||||||
import {EditPunishmentDialogComponent} from '../edit-punishment-dialog/edit-punishment-dialog.component';
|
|
||||||
import {MatIconModule} from '@angular/material/icon';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-history',
|
selector: 'app-history',
|
||||||
imports: [
|
imports: [
|
||||||
NgOptimizedImage,
|
NgOptimizedImage,
|
||||||
RemoveTrailingPeriodPipe,
|
RemoveTrailingPeriodPipe
|
||||||
MatIconModule,
|
|
||||||
],
|
],
|
||||||
templateUrl: './history.component.html',
|
templateUrl: './history.component.html',
|
||||||
styleUrl: './history.component.scss',
|
styleUrl: './history.component.scss',
|
||||||
|
|
@ -40,11 +35,8 @@ export class HistoryComponent implements OnInit, OnChanges {
|
||||||
|
|
||||||
public history: PunishmentHistory[] = []
|
public history: PunishmentHistory[] = []
|
||||||
|
|
||||||
private historyApi: HistoryService = inject(HistoryService)
|
constructor(private historyApi: HistoryService, public historyFormat: HistoryFormatService, private router: Router) {
|
||||||
public historyFormat: HistoryFormatService = inject(HistoryFormatService)
|
}
|
||||||
private router: Router = inject(Router)
|
|
||||||
private authService: AuthService = inject(AuthService)
|
|
||||||
private dialog: MatDialog = inject(MatDialog)
|
|
||||||
|
|
||||||
ngOnChanges(): void {
|
ngOnChanges(): void {
|
||||||
this.reloadHistory();
|
this.reloadHistory();
|
||||||
|
|
@ -109,21 +101,4 @@ export class HistoryComponent implements OnInit, OnChanges {
|
||||||
public showDetailedPunishment(entry: PunishmentHistory) {
|
public showDetailedPunishment(entry: PunishmentHistory) {
|
||||||
this.router.navigate([`bans/${entry.type}/${entry.id}`]).then();
|
this.router.navigate([`bans/${entry.type}/${entry.id}`]).then();
|
||||||
}
|
}
|
||||||
|
|
||||||
public canEdit(): boolean {
|
|
||||||
return this.authService.hasAccess(['SCOPE_head_mod']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public openEdit(punishment: PunishmentHistory) {
|
|
||||||
if (!this.canEdit()) return;
|
|
||||||
const ref = this.dialog.open(EditPunishmentDialogComponent, {
|
|
||||||
data: {punishment},
|
|
||||||
width: '500px',
|
|
||||||
});
|
|
||||||
ref.afterClosed().subscribe(result => {
|
|
||||||
if (result) {
|
|
||||||
this.reloadHistory();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,27 +41,78 @@
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<section class="voteSection">
|
<section class="voteSection">
|
||||||
<div class="container voteContainer">
|
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
||||||
@for (voteSite of Object.keys(voteSites); track voteSite) {
|
<div class="vote">
|
||||||
<div class="vote">
|
<h2>MinecraftServers</h2>
|
||||||
<h2>{{ voteSite }}</h2>
|
<div>
|
||||||
<div>
|
<a onclick="clickVote('vote1');" oncontextmenu="clickVote('vote1');" target="_blank" rel="noopener"
|
||||||
<a (click)="clickVote(voteSite)" (contextmenu)="clickVote(voteSite); $event.preventDefault()"
|
href="https://minecraftservers.org/vote/284208">
|
||||||
target="_blank" rel="noopener"
|
<div class="button-outer">
|
||||||
[href]="voteSites[voteSite]">
|
<span id="vote1" class="button-inner">Vote!</span>
|
||||||
<div class=button-outer [class.not-available-button-outer]="!canVote(voteSite)"
|
|
||||||
[class.available-button-outer]="canVote(voteSite)">
|
|
||||||
<span class="button-inner">{{ getVoteText(voteSite) }}</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
@if (voteStats) {
|
|
||||||
<div class="voteStats">
|
|
||||||
<p>Last voted: {{ getLastVoted(voteSite) | TimeAgo: true }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
|
<div class="vote">
|
||||||
|
<h2>TopMinecraftServers</h2>
|
||||||
|
<div>
|
||||||
|
<a (click)="clickVote('vote2')" oncontextmenu="clickVote('vote2'); return false;" target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
href="https://topminecraftservers.org/vote/4906">
|
||||||
|
<div class="button-outer">
|
||||||
|
<span id="vote2" class="button-inner">Vote!</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vote">
|
||||||
|
<h2>MCSL</h2>
|
||||||
|
<div>
|
||||||
|
<a (click)="clickVote('vote3')" oncontextmenu="clickVote('vote3'); return false;" target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
href="https://minecraft-server-list.com/server/298238/vote/">
|
||||||
|
<div class="button-outer">
|
||||||
|
<span id="vote3" class="button-inner">Vote!</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vote">
|
||||||
|
<h2>Minecraft-Server</h2>
|
||||||
|
<div>
|
||||||
|
<a (click)="clickVote('vote4')" oncontextmenu="clickVote('vote4'); return false;" target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
href="https://minecraft-server.net/vote/Altitude/">
|
||||||
|
<div class="button-outer">
|
||||||
|
<span id="vote4" class="button-inner">Vote!</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vote">
|
||||||
|
<h2>PlanetMinecraft</h2>
|
||||||
|
<div>
|
||||||
|
<a (click)="clickVote('vote5')" oncontextmenu="clickVote('vote5'); return false;" target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
href="https://www.planetminecraft.com/server/alttd/vote/">
|
||||||
|
<div class="button-outer">
|
||||||
|
<span id="vote5" class="button-inner">Vote!</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vote">
|
||||||
|
<h2>Minecraft-MP</h2>
|
||||||
|
<div>
|
||||||
|
<a (click)="clickVote('vote6')" oncontextmenu="clickVote('vote6'); return false;" target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
href="https://minecraft-mp.com/server/98955/vote/">
|
||||||
|
<div class="button-outer">
|
||||||
|
<span id="vote6" class="button-inner">Vote!</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="darkmodeSection">
|
<section class="darkmodeSection">
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,6 @@
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voteContainer {
|
|
||||||
padding: 50px 0 0 0;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voteSection {
|
.voteSection {
|
||||||
background-color: var(--link-color);
|
background-color: var(--link-color);
|
||||||
transition: 0.5s ease;
|
transition: 0.5s ease;
|
||||||
|
|
@ -41,11 +36,3 @@
|
||||||
color: black;
|
color: black;
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.available-button-outer {
|
|
||||||
background-color: #4caf50 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.not-available-button-outer {
|
|
||||||
background-color: var(--white) !important;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,24 @@
|
||||||
import {Component, effect, inject, OnDestroy, OnInit} from '@angular/core';
|
import {Component} from '@angular/core';
|
||||||
import {ScrollService} from '@services/scroll.service';
|
import {ScrollService} from '@services/scroll.service';
|
||||||
|
|
||||||
import {HeaderComponent} from '@header/header.component';
|
import {HeaderComponent} from '@header/header.component';
|
||||||
import {SiteService, VoteData} from '@api';
|
|
||||||
import {AuthService} from '@services/auth.service';
|
|
||||||
import {interval, Subscription} from 'rxjs';
|
|
||||||
import {TimeAgoPipe} from '@pipes/TimeAgoPipe';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-vote',
|
selector: 'app-vote',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
HeaderComponent,
|
HeaderComponent
|
||||||
TimeAgoPipe
|
],
|
||||||
],
|
|
||||||
templateUrl: './vote.component.html',
|
templateUrl: './vote.component.html',
|
||||||
styleUrl: './vote.component.scss'
|
styleUrl: './vote.component.scss'
|
||||||
})
|
})
|
||||||
export class VoteComponent implements OnInit, OnDestroy {
|
export class VoteComponent {
|
||||||
private readonly defaultVoteMessage = 'Vote!';
|
constructor(public scrollService: ScrollService) {
|
||||||
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/',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected scrollService: ScrollService = inject(ScrollService);
|
voteMessage: string = '';
|
||||||
protected siteService = inject(SiteService)
|
|
||||||
protected authService = inject(AuthService)
|
|
||||||
|
|
||||||
protected voteStats: VoteData | null = null
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
if (this.authService.isAuthenticated$()) {
|
|
||||||
this.loadVoteStats();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.refreshSubscription = interval(300000).subscribe(() => {
|
|
||||||
this.loadVoteStats();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.refreshSubscription?.unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
clickVote(id: string) {
|
clickVote(id: string) {
|
||||||
this.voteMessages[id] = this.clickedVoteMessage;
|
this.voteMessage = 'Clicked!';
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import {Pipe, PipeTransform} from '@angular/core';
|
|
||||||
|
|
||||||
@Pipe({
|
|
||||||
name: 'TimeAgo',
|
|
||||||
standalone: true
|
|
||||||
})
|
|
||||||
export class TimeAgoPipe implements PipeTransform {
|
|
||||||
transform(value: Date | string | number | null, short?: boolean): string {
|
|
||||||
if (!value) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(value);
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
||||||
|
|
||||||
if (seconds < 60) {
|
|
||||||
return 'just now';
|
|
||||||
}
|
|
||||||
|
|
||||||
let returnText = 'ago';
|
|
||||||
|
|
||||||
const allMinutes = Math.floor(seconds / 60);
|
|
||||||
const minutes = allMinutes % 60;
|
|
||||||
if (short) {
|
|
||||||
returnText = `${minutes}m ${returnText}`
|
|
||||||
} else {
|
|
||||||
returnText = `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ${returnText}`
|
|
||||||
}
|
|
||||||
if (allMinutes < 60) {
|
|
||||||
return returnText
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const allHours = Math.floor(allMinutes / 60);
|
|
||||||
const hours = allHours % 24;
|
|
||||||
if (short) {
|
|
||||||
returnText = `${hours}h ${returnText}`
|
|
||||||
} else {
|
|
||||||
returnText = `${hours} ${hours === 1 ? 'hour' : 'hours'} ${returnText}`
|
|
||||||
}
|
|
||||||
if (allHours < 24) {
|
|
||||||
return returnText
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = Math.floor(allHours / 24);
|
|
||||||
if (short) {
|
|
||||||
returnText = `${days}d ${returnText}`
|
|
||||||
} else {
|
|
||||||
returnText = `${days} ${days === 1 ? 'day' : 'days'} ${returnText}`
|
|
||||||
}
|
|
||||||
return returnText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -68,7 +68,7 @@ export class AuthService {
|
||||||
/**
|
/**
|
||||||
* Check if the user is authenticated
|
* Check if the user is authenticated
|
||||||
*/
|
*/
|
||||||
private checkAuthStatus(): boolean {
|
public checkAuthStatus(): boolean {
|
||||||
const jwt = this.getJwt();
|
const jwt = this.getJwt();
|
||||||
if (!jwt) {
|
if (!jwt) {
|
||||||
console.log("No JWT found");
|
console.log("No JWT found");
|
||||||
|
|
@ -82,6 +82,7 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const claims = this.extractJwtClaims(jwt);
|
const claims = this.extractJwtClaims(jwt);
|
||||||
|
console.log("User claims: ", claims);
|
||||||
this.userClaimsSubject.set(claims);
|
this.userClaimsSubject.set(claims);
|
||||||
this.isAuthenticatedSubject.set(true);
|
this.isAuthenticatedSubject.set(true);
|
||||||
if (this.username() == null) {
|
if (this.username() == null) {
|
||||||
|
|
@ -108,6 +109,7 @@ export class AuthService {
|
||||||
localStorage.setItem('jwt', jwt);
|
localStorage.setItem('jwt', jwt);
|
||||||
|
|
||||||
const claims = this.extractJwtClaims(jwt);
|
const claims = this.extractJwtClaims(jwt);
|
||||||
|
console.log("Saving user claims: ", claims);
|
||||||
this.userClaimsSubject.set(claims);
|
this.userClaimsSubject.set(claims);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import {provideRouter} from '@angular/router';
|
||||||
import {routes} from './app/app.routes';
|
import {routes} from './app/app.routes';
|
||||||
import {provideHttpClient, withInterceptors} from '@angular/common/http';
|
import {provideHttpClient, withInterceptors} from '@angular/common/http';
|
||||||
import {authInterceptor} from '@services/AuthInterceptor';
|
import {authInterceptor} from '@services/AuthInterceptor';
|
||||||
import {provideNativeDateAdapter} from '@angular/material/core';
|
|
||||||
|
|
||||||
bootstrapApplication(AppComponent, {
|
bootstrapApplication(AppComponent, {
|
||||||
providers: [
|
providers: [
|
||||||
|
|
@ -13,7 +12,6 @@ bootstrapApplication(AppComponent, {
|
||||||
provideHttpClient(
|
provideHttpClient(
|
||||||
withInterceptors([authInterceptor])
|
withInterceptors([authInterceptor])
|
||||||
),
|
),
|
||||||
provideNativeDateAdapter()
|
|
||||||
]
|
]
|
||||||
}).catch(err => console.error(err));
|
}).catch(err => console.error(err));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ tags:
|
||||||
description: All action related to appeals
|
description: All action related to appeals
|
||||||
- name: mail
|
- name: mail
|
||||||
description: All actions related to user email verification
|
description: All actions related to user email verification
|
||||||
- name: site
|
|
||||||
description: Actions related to small features on the site such as displaying vote stats or pt/rank stats
|
|
||||||
paths:
|
paths:
|
||||||
/api/team/{team}:
|
/api/team/{team}:
|
||||||
$ref: './schemas/team/team.yml#/getTeam'
|
$ref: './schemas/team/team.yml#/getTeam'
|
||||||
|
|
@ -51,12 +49,6 @@ paths:
|
||||||
$ref: './schemas/bans/bans.yml#/getAllHistoryForUUID'
|
$ref: './schemas/bans/bans.yml#/getAllHistoryForUUID'
|
||||||
/api/history/total:
|
/api/history/total:
|
||||||
$ref: './schemas/bans/bans.yml#/getTotalPunishments'
|
$ref: './schemas/bans/bans.yml#/getTotalPunishments'
|
||||||
/api/history/admin/{type}/{id}/reason:
|
|
||||||
$ref: './schemas/bans/bans.yml#/updatePunishmentReason'
|
|
||||||
/api/history/admin/{type}/{id}/until:
|
|
||||||
$ref: './schemas/bans/bans.yml#/updatePunishmentUntil'
|
|
||||||
/api/history/admin/{type}/{id}:
|
|
||||||
$ref: './schemas/bans/bans.yml#/removePunishment'
|
|
||||||
/api/appeal/update-mail:
|
/api/appeal/update-mail:
|
||||||
$ref: './schemas/forms/appeal/appeal.yml#/UpdateMail'
|
$ref: './schemas/forms/appeal/appeal.yml#/UpdateMail'
|
||||||
/api/appeal/minecraft-appeal:
|
/api/appeal/minecraft-appeal:
|
||||||
|
|
@ -91,5 +83,3 @@ paths:
|
||||||
$ref: './schemas/forms/mail/mail.yml#/DeleteEmail'
|
$ref: './schemas/forms/mail/mail.yml#/DeleteEmail'
|
||||||
/api/mail/list:
|
/api/mail/list:
|
||||||
$ref: './schemas/forms/mail/mail.yml#/GetEmails'
|
$ref: './schemas/forms/mail/mail.yml#/GetEmails'
|
||||||
/api/site/vote:
|
|
||||||
$ref: './schemas/site/vote.yml#/VoteStats'
|
|
||||||
|
|
|
||||||
|
|
@ -211,73 +211,6 @@ getAllHistoryForUUID:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/PunishmentHistoryList'
|
$ref: '#/components/schemas/PunishmentHistoryList'
|
||||||
updatePunishmentReason:
|
|
||||||
patch:
|
|
||||||
tags:
|
|
||||||
- history
|
|
||||||
summary: Update punishment reason
|
|
||||||
description: Updates the reason for a specific punishment history entry
|
|
||||||
operationId: updatePunishmentReason
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/HistoryType'
|
|
||||||
- $ref: '#/components/parameters/Id'
|
|
||||||
- $ref: '#/components/parameters/NewPunishmentReason'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Updated punishment
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/PunishmentHistory'
|
|
||||||
default:
|
|
||||||
description: Unexpected error
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
|
||||||
updatePunishmentUntil:
|
|
||||||
patch:
|
|
||||||
tags:
|
|
||||||
- history
|
|
||||||
summary: Update punishment expiry time
|
|
||||||
description: Updates the expiry time (until) for a specific punishment history entry (only for ban and mute)
|
|
||||||
operationId: updatePunishmentUntil
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/HistoryType'
|
|
||||||
- $ref: '#/components/parameters/Id'
|
|
||||||
- $ref: '#/components/parameters/NewUntil'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Updated punishment
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/PunishmentHistory'
|
|
||||||
default:
|
|
||||||
description: Unexpected error
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
|
||||||
removePunishment:
|
|
||||||
delete:
|
|
||||||
tags:
|
|
||||||
- history
|
|
||||||
summary: Remove punishment
|
|
||||||
description: Removes a punishment from history. For bans and mutes, the punishment is deactivated; for kicks and warnings, the row is deleted.
|
|
||||||
operationId: removePunishment
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/HistoryType'
|
|
||||||
- $ref: '#/components/parameters/Id'
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Punishment removed
|
|
||||||
default:
|
|
||||||
description: Unexpected error
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '../generic/errors.yml#/components/schemas/ApiError'
|
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
HistoryType:
|
HistoryType:
|
||||||
|
|
@ -317,21 +250,6 @@ components:
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
description: The id of the punishment that should be retrieved
|
description: The id of the punishment that should be retrieved
|
||||||
NewPunishmentReason:
|
|
||||||
name: reason
|
|
||||||
in: query
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
description: The new reason to set for the punishment
|
|
||||||
NewUntil:
|
|
||||||
name: until
|
|
||||||
in: query
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
format: int64
|
|
||||||
description: The new expiry time (epoch millis)
|
|
||||||
schemas:
|
schemas:
|
||||||
SearchResults:
|
SearchResults:
|
||||||
type: integer
|
type: integer
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
VoteStats:
|
|
||||||
get:
|
|
||||||
tags:
|
|
||||||
- site
|
|
||||||
summary: Get vote stats
|
|
||||||
description: Get vote stats for current user
|
|
||||||
operationId: getVoteStats
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Vote stats retrieved
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/VoteData'
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
VoteData:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- allVoteInfo
|
|
||||||
- voteStats
|
|
||||||
- voteStreak
|
|
||||||
- bestVoteStreak
|
|
||||||
properties:
|
|
||||||
allVoteInfo:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/VoteInfo'
|
|
||||||
voteStats:
|
|
||||||
$ref: '#/components/schemas/VoteStats'
|
|
||||||
voteStreak:
|
|
||||||
$ref: '#/components/schemas/VoteStreak'
|
|
||||||
bestVoteStreak:
|
|
||||||
$ref: '#/components/schemas/VoteStreak'
|
|
||||||
VoteInfo:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- siteName
|
|
||||||
- lastVoteTimestamp
|
|
||||||
properties:
|
|
||||||
siteName:
|
|
||||||
type: string
|
|
||||||
lastVoteTimestamp:
|
|
||||||
type: integer
|
|
||||||
format: int64
|
|
||||||
VoteStats:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- total
|
|
||||||
- monthly
|
|
||||||
- weekly
|
|
||||||
- daily
|
|
||||||
properties:
|
|
||||||
total:
|
|
||||||
type: integer
|
|
||||||
format: int32
|
|
||||||
monthly:
|
|
||||||
type: integer
|
|
||||||
format: int32
|
|
||||||
weekly:
|
|
||||||
type: integer
|
|
||||||
format: int32
|
|
||||||
daily:
|
|
||||||
type: integer
|
|
||||||
format: int32
|
|
||||||
VoteStreak:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- dailyStreak
|
|
||||||
- weeklyStreak
|
|
||||||
- monthlyStreak
|
|
||||||
properties:
|
|
||||||
dailyStreak:
|
|
||||||
type: integer
|
|
||||||
format: int32
|
|
||||||
weeklyStreak:
|
|
||||||
type: integer
|
|
||||||
format: int32
|
|
||||||
monthlyStreak:
|
|
||||||
type: integer
|
|
||||||
format: int32
|
|
||||||
Loading…
Reference in New Issue
Block a user