Compare commits

..

6 Commits

35 changed files with 1117 additions and 131 deletions

View File

@ -9,6 +9,7 @@ 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;
@ -17,16 +18,22 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.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
@ -47,13 +54,14 @@ public class SecurityConfig {
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/login/userLogin/**").permitAll()
.anyRequest().permitAll()
)
.csrf(AbstractHttpConfigurer::disable)
.oauth2ResourceServer(
oauth2 -> oauth2
.jwt(Customizer.withDefaults())
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
.authenticationEntryPoint(securityAuthFailureHandler)
.accessDeniedHandler(securityAuthFailureHandler)
)
@ -84,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;
}
}

View File

@ -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);
}

View File

@ -1,24 +1,33 @@
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 static UUID getAuthenticatedUserUuid() {
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");
}

View File

@ -33,6 +33,7 @@ import java.util.concurrent.TimeUnit;
@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;
@ -55,7 +56,7 @@ public class ApplicationController implements ApplicationsApi {
if (!isOpen()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
UUID userUuid = AuthenticatedUuid.getAuthenticatedUserUuid();
UUID userUuid = authenticatedUuid.getAuthenticatedUserUuid();
String email = staffApplicationDto.getEmail() == null ? null : staffApplicationDto.getEmail().toLowerCase();
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, email);

View File

@ -28,11 +28,12 @@ import java.util.concurrent.TimeUnit;
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();
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream()
.filter(EmailVerification::verified)
.map(EmailVerification::email)
@ -52,7 +53,7 @@ public class MailController implements MailApi {
@Override
@RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify")
public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode());
if (optionalEmailVerification.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code");
@ -68,7 +69,7 @@ public class MailController implements MailApi {
@Override
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend")
public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
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");
@ -83,7 +84,7 @@ public class MailController implements MailApi {
@Override
@RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete")
public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail());
if (!deleted) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
@ -93,7 +94,7 @@ public class MailController implements MailApi {
@Override
public ResponseEntity<List<EmailEntryDto>> getUserEmails() {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
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()))

View File

@ -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();
}
}

View File

@ -37,6 +37,7 @@ import java.util.concurrent.TimeUnit;
public class LoginController implements LoginApi {
private final JwtEncoder jwtEncoder;
private final AuthenticatedUuid authenticatedUuid;
@Value("${login.secret:#{null}}")
private String loginSecret;
@ -99,7 +100,7 @@ public class LoginController implements LoginApi {
try {
// Get authenticated UUID using the utility method
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
log.debug("Loaded username for logged in user {}", uuid);
// Create response with username

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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
}
}

View File

@ -1,4 +1,4 @@
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080}
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080,http://localhost:80}
my-server.address=${SERVER_ADDRESS:http://localhost:8080}
logging.level.com.alttd.altitudeweb=DEBUG
logging.level.org.springframework.security=DEBUG

View File

@ -7,7 +7,8 @@ public enum Databases {
DEFAULT("web_db"),
LUCK_PERMS("luckperms"),
LITE_BANS("litebans"),
DISCORD("discordLink");
DISCORD("discordLink"),
VOTING_PLUGIN("votingplugin");
private final String internalName;

View File

@ -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);
};
}
}

View File

@ -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);
}

View File

@ -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
) { }

View File

@ -38,6 +38,7 @@ public class Connection {
InitializeLiteBans.init();
InitializeLuckPerms.init();
InitializeDiscord.init();
InitializeVotingPlugin.init();
}
@FunctionalInterface

View File

@ -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);

View File

@ -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");
}
}

View File

@ -21,7 +21,7 @@ export class AuthGuard implements CanActivate {
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
if (!this.authService.checkAuthStatus()) {
if (!this.authService.isAuthenticated$()) {
this.router.createUrlTree(['/']);
const dialogRef = this.dialog.open(LoginDialogComponent, {
width: '400px',

View File

@ -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>

View File

@ -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;
}

View File

@ -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';
}
}

View File

@ -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`;

View File

@ -6,14 +6,17 @@
<table [cellSpacing]="0">
<div class="historyTableHead">
<thead>
<tr>
<th class="historyType">Type</th>
<th class="historyPlayer">Player</th>
<th class="historyPlayer">Banned By</th>
<th class="historyReason">Reason</th>
<th class="historyDate">Date</th>
<th class="historyDate">Expires</th>
</tr>
<tr>
<th class="historyType">Type</th>
<th class="historyPlayer">Player</th>
<th class="historyPlayer">Banned By</th>
<th class="historyReason">Reason</th>
<th class="historyDate">Date</th>
<th class="historyDate">Expires</th>
@if (canEdit()) {
<th class="historyActions"></th>
}
</tr>
</thead>
</div>
<div>
@ -26,30 +29,36 @@
<td class="historyPlayer" (click)="setSearch(entry.username, 'player')">
<div class="playerContainer">
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.uuid)" width="25" height="25"
alt="{{entry.username}}'s Minecraft skin">
<span class="username">{{ entry.username }}</span>
</div>
alt="{{entry.username}}'s Minecraft skin">
<span class="username">{{ entry.username }}</span>
</div>
</td>
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
<div class="playerContainer">
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25"
height="25"
alt="{{entry.punishedBy}}'s Minecraft skin">
<span>{{ entry.punishedBy }}</span>
</div>
</td>
<td class="historyReason" (click)="showDetailedPunishment(entry)">
{{ entry.reason | removeTrailingPeriod }}
</td>
<td class="historyDate" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getPunishmentTime(entry) }}
</td>
<td class="historyDate" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getExpiredTime(entry) }}
</td>
@if (canEdit()) {
<td class="historyActions" (click)="openEdit(entry)">
<mat-icon>edit</mat-icon>
</td>
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
<div class="playerContainer">
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25" height="25"
alt="{{entry.punishedBy}}'s Minecraft skin">
<span>{{ entry.punishedBy }}</span>
</div>
</td>
<td class="historyReason" (click)="showDetailedPunishment(entry)">
{{ entry.reason | removeTrailingPeriod }}
</td>
<td class="historyDate" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getPunishmentTime(entry) }}
</td>
<td class="historyDate" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getExpiredTime(entry) }}
</td>
</tr>
}
</tbody>
</div>
</table>
}
</tr>
}
</tbody>
</div>
</table>
}

View File

@ -111,3 +111,8 @@ img {
.historyDate {
width: 170px;
}
.historyActions {
width: 100px;
padding: 0 10px 0 10px;
}

View File

@ -1,4 +1,4 @@
import {Component, EventEmitter, Input, OnChanges, OnInit, Output} from '@angular/core';
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';
@ -8,12 +8,17 @@ import {HttpErrorResponse} from '@angular/common/http';
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',
@ -35,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();
@ -101,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();
}
});
}
}

View File

@ -41,78 +41,27 @@
</section>
</section>
<section class="voteSection">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<div class="vote">
<h2>MinecraftServers</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>
<div class="container voteContainer">
@for (voteSite of Object.keys(voteSites); track voteSite) {
<div class="vote">
<h2>{{ voteSite }}</h2>
<div>
<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>
</a>
}
</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>
</section>
<section class="darkmodeSection">

View File

@ -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;
}

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)
)
}
}

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
}
}

View File

@ -68,7 +68,7 @@ export class AuthService {
/**
* Check if the user is authenticated
*/
public checkAuthStatus(): boolean {
private checkAuthStatus(): boolean {
const jwt = this.getJwt();
if (!jwt) {
console.log("No JWT found");
@ -82,7 +82,6 @@ export class AuthService {
}
const claims = this.extractJwtClaims(jwt);
console.log("User claims: ", claims);
this.userClaimsSubject.set(claims);
this.isAuthenticatedSubject.set(true);
if (this.username() == null) {
@ -109,7 +108,6 @@ export class AuthService {
localStorage.setItem('jwt', jwt);
const claims = this.extractJwtClaims(jwt);
console.log("Saving user claims: ", claims);
this.userClaimsSubject.set(claims);
}

View File

@ -4,6 +4,7 @@ import {provideRouter} from '@angular/router';
import {routes} from './app/app.routes';
import {provideHttpClient, withInterceptors} from '@angular/common/http';
import {authInterceptor} from '@services/AuthInterceptor';
import {provideNativeDateAdapter} from '@angular/material/core';
bootstrapApplication(AppComponent, {
providers: [
@ -12,6 +13,7 @@ bootstrapApplication(AppComponent, {
provideHttpClient(
withInterceptors([authInterceptor])
),
provideNativeDateAdapter()
]
}).catch(err => console.error(err));

View File

@ -28,6 +28,8 @@ tags:
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'
@ -49,6 +51,12 @@ 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:
@ -83,3 +91,5 @@ paths:
$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'

View File

@ -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

View File

@ -0,0 +1,81 @@
VoteStats:
get:
tags:
- site
summary: Get vote stats
description: Get vote stats for current user
operationId: getVoteStats
responses:
'200':
description: Vote stats retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/VoteData'
components:
schemas:
VoteData:
type: object
required:
- allVoteInfo
- voteStats
- voteStreak
- bestVoteStreak
properties:
allVoteInfo:
type: array
items:
$ref: '#/components/schemas/VoteInfo'
voteStats:
$ref: '#/components/schemas/VoteStats'
voteStreak:
$ref: '#/components/schemas/VoteStreak'
bestVoteStreak:
$ref: '#/components/schemas/VoteStreak'
VoteInfo:
type: object
required:
- siteName
- lastVoteTimestamp
properties:
siteName:
type: string
lastVoteTimestamp:
type: integer
format: int64
VoteStats:
type: object
required:
- total
- monthly
- weekly
- daily
properties:
total:
type: integer
format: int32
monthly:
type: integer
format: int32
weekly:
type: integer
format: int32
daily:
type: integer
format: int32
VoteStreak:
type: object
required:
- dailyStreak
- weeklyStreak
- monthlyStreak
properties:
dailyStreak:
type: integer
format: int32
weeklyStreak:
type: integer
format: int32
monthlyStreak:
type: integer
format: int32