From 770a2e0d14940e9eea5eb2a470832cb68e0d3879 Mon Sep 17 00:00:00 2001 From: akastijn Date: Wed, 13 Aug 2025 23:54:20 +0200 Subject: [PATCH] Add Minecraft appeal functionality with database integration, UUID handling, and API response adjustments. --- .../data_from_auth/AuthenticatedUuid.java | 33 +++++++ .../controllers/forms/AppealController.java | 39 +++++++- .../controllers/login/LoginController.java | 88 ++++++++++--------- .../altitudeweb/mappers/AppealDataMapper.java | 30 +++++++ .../database/web_db/PrivilegedUser.java | 3 +- .../database/web_db/PrivilegedUserMapper.java | 10 ++- .../database/web_db/forms/Appeal.java | 16 ++++ .../database/web_db/forms/AppealMapper.java | 44 ++++++++++ .../altitudeweb/setup/InitializeWebDb.java | 36 ++++++-- .../resources/schemas/forms/appeal/appeal.yml | 6 +- 10 files changed, 253 insertions(+), 52 deletions(-) create mode 100644 backend/src/main/java/com/alttd/altitudeweb/controllers/data_from_auth/AuthenticatedUuid.java create mode 100644 backend/src/main/java/com/alttd/altitudeweb/mappers/AppealDataMapper.java create mode 100644 database/src/main/java/com/alttd/altitudeweb/database/web_db/forms/Appeal.java create mode 100644 database/src/main/java/com/alttd/altitudeweb/database/web_db/forms/AppealMapper.java diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/data_from_auth/AuthenticatedUuid.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/data_from_auth/AuthenticatedUuid.java new file mode 100644 index 0000000..204239d --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/data_from_auth/AuthenticatedUuid.java @@ -0,0 +1,33 @@ +package com.alttd.altitudeweb.controllers.data_from_auth; + +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.web.server.ResponseStatusException; + +import java.util.UUID; + +public class AuthenticatedUuid { + /** + * 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() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required"); + } + + String stringUuid = jwt.getSubject(); + + try { + return UUID.fromString(stringUuid); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid UUID format"); + } + } +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/AppealController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/AppealController.java index d78eebe..1f8096d 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/AppealController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/AppealController.java @@ -1,32 +1,65 @@ package com.alttd.altitudeweb.controllers.forms; import com.alttd.altitudeweb.api.AppealsApi; -import com.alttd.altitudeweb.services.limits.RateLimit; +import com.alttd.altitudeweb.database.Databases; +import com.alttd.altitudeweb.database.web_db.forms.Appeal; +import com.alttd.altitudeweb.database.web_db.forms.AppealMapper; +import com.alttd.altitudeweb.mappers.AppealDataMapper; import com.alttd.altitudeweb.model.AppealResponseDto; import com.alttd.altitudeweb.model.DiscordAppealDto; import com.alttd.altitudeweb.model.MinecraftAppealDto; import com.alttd.altitudeweb.model.UpdateMailDto; +import com.alttd.altitudeweb.services.limits.RateLimit; +import com.alttd.altitudeweb.setup.Connection; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +@Slf4j @RestController +@AllArgsConstructor @RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS) public class AppealController implements AppealsApi { + private final AppealDataMapper mapper; + @RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal") @Override - public ResponseEntity submitDiscordAppeal(DiscordAppealDto discordAppealDto) { + public ResponseEntity submitDiscordAppeal(DiscordAppealDto discordAppealDto) { throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported"); } @RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal") @Override public ResponseEntity submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) { - throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Minecraft appeals are not yet supported"); + CompletableFuture appealCompletableFuture = new CompletableFuture<>(); + + Connection.getConnection(Databases.DEFAULT) + .runQuery(sqlSession -> { + log.debug("Loading history by id"); + try { + Appeal appeal = sqlSession.getMapper(AppealMapper.class) + .createAppeal(mapper.minecraftAppealDtoToAppeal(minecraftAppealDto)); + appealCompletableFuture.complete(appeal); + } catch (Exception e) { + log.error("Failed to load history count", e); + appealCompletableFuture.completeExceptionally(e); + } + }); + Appeal appeal = appealCompletableFuture.join(); + + AppealResponseDto appealResponseDto = new AppealResponseDto( + appeal.id().toString(), + "Your appeal has been submitted. You will be notified when it has been reviewed.", + false); + + return ResponseEntity.ok().body(appealResponseDto); } @Override diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java index 71b36ee..464b705 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java @@ -1,6 +1,7 @@ package com.alttd.altitudeweb.controllers.login; import com.alttd.altitudeweb.api.LoginApi; +import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid; import com.alttd.altitudeweb.database.litebans.HistoryRecord; import com.alttd.altitudeweb.database.litebans.RecentNamesMapper; import com.alttd.altitudeweb.database.litebans.UUIDHistoryMapper; @@ -47,7 +48,8 @@ public class LoginController implements LoginApi { @Value("${my-server.address:#{null}}") private String serverAddress; - private record CacheEntry(UUID uuid, Instant expiry) {} + private record CacheEntry(UUID uuid, Instant expiry) { + } private static final ConcurrentMap cache = new ConcurrentHashMap<>(); @@ -79,9 +81,9 @@ public class LoginController implements LoginApi { } Optional key = cache.entrySet().stream() - .filter(entry -> entry.getValue().uuid.equals(uuidFromString)) - .map(Map.Entry::getKey) - .findFirst(); + .filter(entry -> entry.getValue().uuid.equals(uuidFromString)) + .map(Map.Entry::getKey) + .findFirst(); if (key.isPresent()) { return ResponseEntity.ok(key.get()); @@ -94,25 +96,23 @@ public class LoginController implements LoginApi { @Override public ResponseEntity getUsername() { log.debug("Loading username for logged in user"); - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - log.debug("Loaded authentication for logged in user {}", authentication); - if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) { - log.debug("Loaded authentication for logged in user is null or not a jwt"); - return ResponseEntity.status(401).build(); - } - String stringUuid = jwt.getSubject(); - UUID uuid; - try { - uuid = UUID.fromString(stringUuid); - log.debug("Loaded username for logged in user {}", uuid); - } catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().build(); - } - UsernameDto usernameDto = new UsernameDto(); - usernameDto.setUsername(getUsername(uuid)); - log.debug("Loaded username for logged in user {}", usernameDto.getUsername()); - return ResponseEntity.ok(usernameDto); + try { + // Get authenticated UUID using the utility method + UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); + log.debug("Loaded username for logged in user {}", uuid); + + // Create response with username + UsernameDto usernameDto = new UsernameDto(); + usernameDto.setUsername(getUsername(uuid)); + log.debug("Loaded username for logged in user {}", usernameDto.getUsername()); + + return ResponseEntity.ok(usernameDto); + + } catch (ResponseStatusException e) { + // The utility method already throws proper exceptions, we just need to convert them to ResponseEntity + return ResponseEntity.status(e.getStatusCode()).build(); + } } private String getUsername(UUID uuid) { @@ -165,7 +165,7 @@ public class LoginController implements LoginApi { loginCode.append(characters.charAt(index)); } CacheEntry cacheEntry = new CacheEntry(uuid, - Instant.now().plusSeconds(TimeUnit.MINUTES.toSeconds(15))); + Instant.now().plusSeconds(TimeUnit.MINUTES.toSeconds(15))); cache.put(loginCode.toString(), cacheEntry); return loginCode.toString(); } @@ -193,18 +193,25 @@ public class LoginController implements LoginApi { CompletableFuture> privilegedUserCompletableFuture = new CompletableFuture<>(); List claimList = new ArrayList<>(); Connection.getConnection(Databases.DEFAULT) - .runQuery(sqlSession -> { - try { - log.debug("Loading user by uuid {}", uuid.toString()); - Optional privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class) - .getUserByUuid(uuid.toString()); + .runQuery(sqlSession -> { + try { + log.debug("Loading user by uuid {}", uuid.toString()); + PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class); + Optional privilegedUser = mapper + .getUserByUuid(uuid); - privilegedUserCompletableFuture.complete(privilegedUser); - } catch (Exception e) { - log.error("Failed to load user by uuid", e); - privilegedUserCompletableFuture.completeExceptionally(e); - } - }); + if (privilegedUser.isEmpty()) { + int privilegedUserId = mapper.createPrivilegedUser(uuid); + privilegedUserCompletableFuture.complete( + Optional.of(new PrivilegedUser(privilegedUserId, uuid, List.of()))); + } else { + privilegedUserCompletableFuture.complete(privilegedUser); + } + } catch (Exception e) { + log.error("Failed to load user by uuid", e); + privilegedUserCompletableFuture.completeExceptionally(e); + } + }); Optional privilegedUser = privilegedUserCompletableFuture.join(); claimList.add(PermissionClaimDto.USER); privilegedUser.ifPresent(user -> user.getPermissions().forEach(permission -> { @@ -218,12 +225,13 @@ public class LoginController implements LoginApi { log.debug("Generated token for user {} with claims {}", uuid.toString(), claimList.stream().map(PermissionClaimDto::getValue).toList()); JwtClaimsSet claims = JwtClaimsSet.builder() - .issuer(serverAddress) - .claim("authorities", claimList.stream().map(PermissionClaimDto::getValue).toList()) - .issuedAt(now) - .expiresAt(expiryTime) - .subject(uuid.toString()) - .build(); + .issuer(serverAddress) + .claim("authorities", + claimList.stream().map(PermissionClaimDto::getValue).toList()) + .issuedAt(now) + .expiresAt(expiryTime) + .subject(uuid.toString()) + .build(); return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); } diff --git a/backend/src/main/java/com/alttd/altitudeweb/mappers/AppealDataMapper.java b/backend/src/main/java/com/alttd/altitudeweb/mappers/AppealDataMapper.java new file mode 100644 index 0000000..a4fee7e --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/mappers/AppealDataMapper.java @@ -0,0 +1,30 @@ +package com.alttd.altitudeweb.mappers; + +import com.alttd.altitudeweb.database.web_db.forms.Appeal; +import com.alttd.altitudeweb.model.MinecraftAppealDto; +import org.springframework.stereotype.Service; + +@Service +public class AppealDataMapper { + public MinecraftAppealDto appealToMinecraftAppealDto(Appeal appeal) { + MinecraftAppealDto minecraftAppealDto = new MinecraftAppealDto(); + minecraftAppealDto.setAppeal(appeal.reason()); + minecraftAppealDto.setUsername(appeal.username()); + minecraftAppealDto.setUuid(appeal.uuid()); + minecraftAppealDto.setEmail(appeal.email()); + return minecraftAppealDto; + } + + public Appeal minecraftAppealDtoToAppeal(MinecraftAppealDto minecraftAppealDto) { + return new Appeal( + null, + minecraftAppealDto.getUuid(), + minecraftAppealDto.getUsername(), + minecraftAppealDto.getAppeal(), + null, + null, + minecraftAppealDto.getEmail(), + null + ); + } +} diff --git a/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUser.java b/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUser.java index 138ad98..acb3859 100644 --- a/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUser.java +++ b/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUser.java @@ -5,12 +5,13 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; +import java.util.UUID; @Data @NoArgsConstructor @AllArgsConstructor public class PrivilegedUser { private int id; - private String uuid; + private UUID uuid; private List permissions; } diff --git a/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUserMapper.java b/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUserMapper.java index ea3d9f3..59ba0e4 100644 --- a/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUserMapper.java +++ b/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUserMapper.java @@ -5,6 +5,7 @@ import org.jetbrains.annotations.Nullable; import java.util.List; import java.util.Optional; +import java.util.UUID; public interface PrivilegedUserMapper { @@ -24,7 +25,7 @@ public interface PrivilegedUserMapper { @Result(property = "permissions", column = "id", javaType = List.class, many = @Many(select = "getPermissionsForUser")) }) - Optional getUserByUuid(@Param("uuid") String uuid); + Optional getUserByUuid(@Param("uuid") UUID uuid); /** * Retrieves all privileged users with their permissions @@ -100,4 +101,11 @@ public interface PrivilegedUserMapper { WHERE user_id = #{userId} AND privileges = #{permission} """) int removePermissionFromUser(@Param("userId") int userId, @Param("permission") String permission); + + @Insert(""" + INSERT INTO privileged_users (uuid) + VALUES (#{uuid}) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + int createPrivilegedUser(UUID uuid); } diff --git a/database/src/main/java/com/alttd/altitudeweb/database/web_db/forms/Appeal.java b/database/src/main/java/com/alttd/altitudeweb/database/web_db/forms/Appeal.java new file mode 100644 index 0000000..04a608e --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/web_db/forms/Appeal.java @@ -0,0 +1,16 @@ +package com.alttd.altitudeweb.database.web_db.forms; + +import java.time.Instant; +import java.util.UUID; + +public record Appeal( + UUID id, + UUID uuid, + String username, + String reason, + Instant createdAt, + Instant sendAt, + String email, + Long assignedTo +) { +} diff --git a/database/src/main/java/com/alttd/altitudeweb/database/web_db/forms/AppealMapper.java b/database/src/main/java/com/alttd/altitudeweb/database/web_db/forms/AppealMapper.java new file mode 100644 index 0000000..d0a0635 --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/web_db/forms/AppealMapper.java @@ -0,0 +1,44 @@ +package com.alttd.altitudeweb.database.web_db.forms; + +import com.alttd.altitudeweb.model.MinecraftAppealDto; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +public interface AppealMapper { + + @Insert(""" + INSERT INTO appeals (uuid, username, reason, created_at, send_at, e_mail, assigned_to) + VALUES (#{uuid}, #{username}, #{reason}, #{createdAt}, #{sendAt}, #{email}, #{assignedTo}) + """) + @Options(useGeneratedKeys = true, keyProperty = "id") + Appeal createAppeal(Appeal appeal); + + @Update(""" + UPDATE appeals + SET reason = #{reason}, + created_at = #{createdAt}, + send_at = #{sendAt}, + e_mail = #{email}, + assigned_to = #{assignedTo} + WHERE id = #{id} + """) + void updateAppeal(Appeal appeal); + + @Select(""" + SELECT id, uuid, reason, created_at AS createdAt, send_at AS sendAt, e_mail AS email, assigned_to AS assignedTo + FROM appeals + WHERE id = #{id} + """) + Appeal getAppealById(int id); + + @Select(""" + SELECT id, uuid, reason, created_at AS createdAt, send_at AS sendAt, e_mail AS email, assigned_to AS assignedTo + FROM appeals + WHERE uuid = #{uuid} + """) + List getAppealsByUuid(String uuid); +} diff --git a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java index 5281707..a8aa398 100644 --- a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java +++ b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java @@ -4,6 +4,7 @@ import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.web_db.KeyPairMapper; import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper; import com.alttd.altitudeweb.database.web_db.SettingsMapper; +import com.alttd.altitudeweb.database.web_db.forms.AppealMapper; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.session.SqlSession; import org.jetbrains.annotations.NotNull; @@ -20,12 +21,14 @@ public class InitializeWebDb { configuration.addMapper(SettingsMapper.class); configuration.addMapper(KeyPairMapper.class); configuration.addMapper(PrivilegedUserMapper.class); + configuration.addMapper(AppealMapper.class); }).join() - .runQuery(SqlSession -> { - createSettingsTable(SqlSession); - createKeyTable(SqlSession); - createPrivilegedUsersTable(SqlSession); - createPrivilegesTable(SqlSession); + .runQuery(sqlSession -> { + createSettingsTable(sqlSession); + createKeyTable(sqlSession); + createPrivilegedUsersTable(sqlSession); + createPrivilegesTable(sqlSession); + createAppealTable(sqlSession); }); log.debug("Initialized WebDb"); } @@ -70,7 +73,7 @@ public class InitializeWebDb { String query = """ CREATE TABLE IF NOT EXISTS privileged_users ( id int NOT NULL AUTO_INCREMENT PRIMARY KEY, - uuid VARCHAR(36) NOT NULL + uuid UUID UNIQUE NOT NULL ); """; try (Statement statement = sqlSession.getConnection().createStatement()) { @@ -99,4 +102,25 @@ public class InitializeWebDb { } } + private static void createAppealTable(@NotNull SqlSession sqlSession) { + String query = """ + CREATE TABLE IF NOT EXISTS appeals ( + id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY, + uuid UUID NOT NULL, + username VARCHAR(16) NOT NULL, + reason TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + send_at TIMESTAMP NULL, + e_mail TEXT NOT NULL, + assigned_to BIGINT UNSIGNED NULL, + FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE + ); + """; + try (Statement statement = sqlSession.getConnection().createStatement()) { + statement.execute(query); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + } diff --git a/open_api/src/main/resources/schemas/forms/appeal/appeal.yml b/open_api/src/main/resources/schemas/forms/appeal/appeal.yml index ca92483..e54fce8 100644 --- a/open_api/src/main/resources/schemas/forms/appeal/appeal.yml +++ b/open_api/src/main/resources/schemas/forms/appeal/appeal.yml @@ -75,7 +75,7 @@ DiscordAppeal: content: application/json: schema: - $ref: '#/components/schemas/MinecraftAppeal' + $ref: '#/components/schemas/AppealResponse' default: description: Unexpected error content: @@ -136,6 +136,7 @@ components: required: - id - message + - verified_mail properties: id: type: string @@ -143,6 +144,9 @@ components: message: type: string description: Confirmation message + verified_mail: + type: boolean + description: If this user has verified their mail already UpdateMail: type: object required: