Add Minecraft appeal functionality with database integration, UUID handling, and API response adjustments.

This commit is contained in:
akastijn 2025-08-13 23:54:20 +02:00
parent 101794d8f2
commit 770a2e0d14
10 changed files with 253 additions and 52 deletions

View File

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

View File

@ -1,32 +1,65 @@
package com.alttd.altitudeweb.controllers.forms; package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.AppealsApi; 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.AppealResponseDto;
import com.alttd.altitudeweb.model.DiscordAppealDto; import com.alttd.altitudeweb.model.DiscordAppealDto;
import com.alttd.altitudeweb.model.MinecraftAppealDto; import com.alttd.altitudeweb.model.MinecraftAppealDto;
import com.alttd.altitudeweb.model.UpdateMailDto; import com.alttd.altitudeweb.model.UpdateMailDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.setup.Connection;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@Slf4j
@RestController @RestController
@AllArgsConstructor
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS) @RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class AppealController implements AppealsApi { public class AppealController implements AppealsApi {
private final AppealDataMapper mapper;
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal") @RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
@Override @Override
public ResponseEntity<MinecraftAppealDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) { public ResponseEntity<AppealResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported"); throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
} }
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal") @RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
@Override @Override
public ResponseEntity<AppealResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) { public ResponseEntity<AppealResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Minecraft appeals are not yet supported"); CompletableFuture<Appeal> 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 @Override

View File

@ -1,6 +1,7 @@
package com.alttd.altitudeweb.controllers.login; package com.alttd.altitudeweb.controllers.login;
import com.alttd.altitudeweb.api.LoginApi; import com.alttd.altitudeweb.api.LoginApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.database.litebans.HistoryRecord; import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.litebans.RecentNamesMapper; import com.alttd.altitudeweb.database.litebans.RecentNamesMapper;
import com.alttd.altitudeweb.database.litebans.UUIDHistoryMapper; import com.alttd.altitudeweb.database.litebans.UUIDHistoryMapper;
@ -47,7 +48,8 @@ public class LoginController implements LoginApi {
@Value("${my-server.address:#{null}}") @Value("${my-server.address:#{null}}")
private String serverAddress; private String serverAddress;
private record CacheEntry(UUID uuid, Instant expiry) {} private record CacheEntry(UUID uuid, Instant expiry) {
}
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>(); private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
@ -79,9 +81,9 @@ public class LoginController implements LoginApi {
} }
Optional<String> key = cache.entrySet().stream() Optional<String> key = cache.entrySet().stream()
.filter(entry -> entry.getValue().uuid.equals(uuidFromString)) .filter(entry -> entry.getValue().uuid.equals(uuidFromString))
.map(Map.Entry::getKey) .map(Map.Entry::getKey)
.findFirst(); .findFirst();
if (key.isPresent()) { if (key.isPresent()) {
return ResponseEntity.ok(key.get()); return ResponseEntity.ok(key.get());
@ -94,25 +96,23 @@ public class LoginController implements LoginApi {
@Override @Override
public ResponseEntity<UsernameDto> getUsername() { public ResponseEntity<UsernameDto> getUsername() {
log.debug("Loading username for logged in user"); 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(); try {
usernameDto.setUsername(getUsername(uuid)); // Get authenticated UUID using the utility method
log.debug("Loaded username for logged in user {}", usernameDto.getUsername()); UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
return ResponseEntity.ok(usernameDto); 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) { private String getUsername(UUID uuid) {
@ -165,7 +165,7 @@ public class LoginController implements LoginApi {
loginCode.append(characters.charAt(index)); loginCode.append(characters.charAt(index));
} }
CacheEntry cacheEntry = new CacheEntry(uuid, CacheEntry cacheEntry = new CacheEntry(uuid,
Instant.now().plusSeconds(TimeUnit.MINUTES.toSeconds(15))); Instant.now().plusSeconds(TimeUnit.MINUTES.toSeconds(15)));
cache.put(loginCode.toString(), cacheEntry); cache.put(loginCode.toString(), cacheEntry);
return loginCode.toString(); return loginCode.toString();
} }
@ -193,18 +193,25 @@ public class LoginController implements LoginApi {
CompletableFuture<Optional<PrivilegedUser>> privilegedUserCompletableFuture = new CompletableFuture<>(); CompletableFuture<Optional<PrivilegedUser>> privilegedUserCompletableFuture = new CompletableFuture<>();
List<PermissionClaimDto> claimList = new ArrayList<>(); List<PermissionClaimDto> claimList = new ArrayList<>();
Connection.getConnection(Databases.DEFAULT) Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> { .runQuery(sqlSession -> {
try { try {
log.debug("Loading user by uuid {}", uuid.toString()); log.debug("Loading user by uuid {}", uuid.toString());
Optional<PrivilegedUser> privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class) PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class);
.getUserByUuid(uuid.toString()); Optional<PrivilegedUser> privilegedUser = mapper
.getUserByUuid(uuid);
privilegedUserCompletableFuture.complete(privilegedUser); if (privilegedUser.isEmpty()) {
} catch (Exception e) { int privilegedUserId = mapper.createPrivilegedUser(uuid);
log.error("Failed to load user by uuid", e); privilegedUserCompletableFuture.complete(
privilegedUserCompletableFuture.completeExceptionally(e); 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> privilegedUser = privilegedUserCompletableFuture.join(); Optional<PrivilegedUser> privilegedUser = privilegedUserCompletableFuture.join();
claimList.add(PermissionClaimDto.USER); claimList.add(PermissionClaimDto.USER);
privilegedUser.ifPresent(user -> user.getPermissions().forEach(permission -> { 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(), log.debug("Generated token for user {} with claims {}", uuid.toString(),
claimList.stream().map(PermissionClaimDto::getValue).toList()); claimList.stream().map(PermissionClaimDto::getValue).toList());
JwtClaimsSet claims = JwtClaimsSet.builder() JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(serverAddress) .issuer(serverAddress)
.claim("authorities", claimList.stream().map(PermissionClaimDto::getValue).toList()) .claim("authorities",
.issuedAt(now) claimList.stream().map(PermissionClaimDto::getValue).toList())
.expiresAt(expiryTime) .issuedAt(now)
.subject(uuid.toString()) .expiresAt(expiryTime)
.build(); .subject(uuid.toString())
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
} }

View File

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

View File

@ -5,12 +5,13 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.List; import java.util.List;
import java.util.UUID;
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class PrivilegedUser { public class PrivilegedUser {
private int id; private int id;
private String uuid; private UUID uuid;
private List<String> permissions; private List<String> permissions;
} }

View File

@ -5,6 +5,7 @@ import org.jetbrains.annotations.Nullable;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
public interface PrivilegedUserMapper { public interface PrivilegedUserMapper {
@ -24,7 +25,7 @@ public interface PrivilegedUserMapper {
@Result(property = "permissions", column = "id", javaType = List.class, @Result(property = "permissions", column = "id", javaType = List.class,
many = @Many(select = "getPermissionsForUser")) many = @Many(select = "getPermissionsForUser"))
}) })
Optional<PrivilegedUser> getUserByUuid(@Param("uuid") String uuid); Optional<PrivilegedUser> getUserByUuid(@Param("uuid") UUID uuid);
/** /**
* Retrieves all privileged users with their permissions * Retrieves all privileged users with their permissions
@ -100,4 +101,11 @@ public interface PrivilegedUserMapper {
WHERE user_id = #{userId} AND privileges = #{permission} WHERE user_id = #{userId} AND privileges = #{permission}
""") """)
int removePermissionFromUser(@Param("userId") int userId, @Param("permission") String 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);
} }

View File

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

View File

@ -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<Appeal> getAppealsByUuid(String uuid);
}

View File

@ -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.KeyPairMapper;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper; import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.database.web_db.SettingsMapper; import com.alttd.altitudeweb.database.web_db.SettingsMapper;
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession; import org.apache.ibatis.session.SqlSession;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@ -20,12 +21,14 @@ public class InitializeWebDb {
configuration.addMapper(SettingsMapper.class); configuration.addMapper(SettingsMapper.class);
configuration.addMapper(KeyPairMapper.class); configuration.addMapper(KeyPairMapper.class);
configuration.addMapper(PrivilegedUserMapper.class); configuration.addMapper(PrivilegedUserMapper.class);
configuration.addMapper(AppealMapper.class);
}).join() }).join()
.runQuery(SqlSession -> { .runQuery(sqlSession -> {
createSettingsTable(SqlSession); createSettingsTable(sqlSession);
createKeyTable(SqlSession); createKeyTable(sqlSession);
createPrivilegedUsersTable(SqlSession); createPrivilegedUsersTable(sqlSession);
createPrivilegesTable(SqlSession); createPrivilegesTable(sqlSession);
createAppealTable(sqlSession);
}); });
log.debug("Initialized WebDb"); log.debug("Initialized WebDb");
} }
@ -70,7 +73,7 @@ public class InitializeWebDb {
String query = """ String query = """
CREATE TABLE IF NOT EXISTS privileged_users ( CREATE TABLE IF NOT EXISTS privileged_users (
id int NOT NULL AUTO_INCREMENT PRIMARY KEY, id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
uuid VARCHAR(36) NOT NULL uuid UUID UNIQUE NOT NULL
); );
"""; """;
try (Statement statement = sqlSession.getConnection().createStatement()) { 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);
}
}
} }

View File

@ -75,7 +75,7 @@ DiscordAppeal:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/MinecraftAppeal' $ref: '#/components/schemas/AppealResponse'
default: default:
description: Unexpected error description: Unexpected error
content: content:
@ -136,6 +136,7 @@ components:
required: required:
- id - id
- message - message
- verified_mail
properties: properties:
id: id:
type: string type: string
@ -143,6 +144,9 @@ components:
message: message:
type: string type: string
description: Confirmation message description: Confirmation message
verified_mail:
type: boolean
description: If this user has verified their mail already
UpdateMail: UpdateMail:
type: object type: object
required: required: