Backend Stability, Basically functional.

This commit is contained in:
2025-10-27 12:38:22 +08:00
parent d0d3373b3b
commit 4ea30cc12e
59 changed files with 5034 additions and 35 deletions

View File

@@ -0,0 +1,14 @@
package com.osleague.groupironmen;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class GroupIronmenApplication {
public static void main(String[] args) {
SpringApplication.run(GroupIronmenApplication.class, args);
}
}

View File

@@ -0,0 +1,47 @@
package com.osleague.groupironmen.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
/**
* CORS configuration to allow frontend and RuneLite plugin access.
*/
@Configuration
public class CorsConfig {
@Value("${app.cors.allowed-origins}")
private String allowedOriginsString;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// Parse comma-separated allowed origins
List<String> allowedOrigins = Arrays.asList(allowedOriginsString.split(","));
configuration.setAllowedOrigins(allowedOrigins);
// Allow all HTTP methods
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
// Allow all headers (including Authorization)
configuration.setAllowedHeaders(List.of("*"));
// Allow credentials
configuration.setAllowCredentials(true);
// Max age for preflight cache
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View File

@@ -0,0 +1,69 @@
package com.osleague.groupironmen.config;
import com.osleague.groupironmen.security.TokenAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Security configuration for Group Ironmen API.
*
* Public endpoints (no authentication):
* - POST /api/create-group
* - GET /api/ge-prices
* - GET /api/captcha-enabled
* - GET /api/collection-log-info
*
* Protected endpoints (token authentication):
* - All /api/group/{group_name}/** endpoints
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenAuthenticationFilter tokenAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// Disable CSRF (stateless API with token auth)
.csrf(AbstractHttpConfigurer::disable)
// Stateless session (no cookies, no server-side sessions)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// Authorization rules
.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers(
"/api/create-group",
"/api/ge-prices",
"/api/captcha-enabled",
"/api/collection-log-info"
).permitAll()
// Protected endpoints (require authentication)
.requestMatchers("/api/group/**").authenticated()
// Actuator endpoints (if enabled)
.requestMatchers("/actuator/**").permitAll()
// All other requests require authentication by default
.anyRequest().authenticated()
)
// Add custom token authentication filter before UsernamePasswordAuthenticationFilter
.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@@ -0,0 +1,157 @@
package com.osleague.groupironmen.controller;
import com.osleague.groupironmen.dto.GroupMemberDto;
import com.osleague.groupironmen.service.GroupService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* Group-related authenticated endpoints.
* All endpoints require valid token authentication.
* Matches Rust authed.rs group endpoints.
*/
@Slf4j
@RestController
@RequestMapping("/api/group/{group_name}")
@RequiredArgsConstructor
public class GroupController {
private final GroupService groupService;
/**
* Get group data with delta updates.
* GET /api/group/{group_name}/get-group-data?from_time=<epoch_millis>
*
* Matches Rust: pub async fn get_group_data()
*
* @param groupName The group name (from path)
* @param fromTime Epoch milliseconds (optional) - only return members updated after this time
* @param auth Authentication (contains group_id as principal)
* @return List of member DTOs
*/
@GetMapping("/get-group-data")
public ResponseEntity<?> getGroupData(
@PathVariable("group_name") String groupName,
@RequestParam(value = "from_time", required = false) String fromTime,
Authentication auth) {
Long groupId = (Long) auth.getPrincipal();
Instant fromTimestamp = null;
if (fromTime != null && !fromTime.isBlank()) {
try {
fromTimestamp = Instant.ofEpochMilli(Long.parseLong(fromTime));
} catch (NumberFormatException e) {
fromTimestamp = Instant.parse(fromTime);
}
}
List<GroupMemberDto> members = groupService.getGroupData(groupId, fromTimestamp);
log.debug("Returning {} members for group_id: {}", members.size(), groupId);
// ✅ Return object wrapper, not raw list
return ResponseEntity.ok(members);
}
/**
* Check if authenticated.
* GET /api/group/{group_name}/am-i-logged-in
*
* Matches Rust: pub async fn am_i_logged_in()
* If this endpoint is reached, authentication succeeded.
*
* @return Success response
*/
@GetMapping("/am-i-logged-in")
public ResponseEntity<Map<String, Boolean>> amILoggedIn() {
log.debug("Authentication check: success");
return ResponseEntity.ok(Map.of("authenticated", true));
}
/**
* Check if a member is in the group.
* GET /api/group/{group_name}/am-i-in-group?member_name=<name>
*
* Matches Rust: pub async fn am_i_in_group()
*
* @param groupName The group name (from path)
* @param memberName The member name to check
* @param auth Authentication (contains group_id)
* @return Map with "in_group" boolean
*/
@GetMapping("/am-i-in-group")
public ResponseEntity<Map<String, Boolean>> amIInGroup(
@PathVariable("group_name") String groupName,
@RequestParam("member_name") String memberName,
Authentication auth) {
Long groupId = (Long) auth.getPrincipal();
log.debug("Checking if member '{}' is in group_id: {}", memberName, groupId);
boolean inGroup = groupService.isMemberInGroup(groupId, memberName);
return ResponseEntity.ok(Map.of("in_group", inGroup));
}
/**
* Get skill data for members.
* GET /api/group/{group_name}/get-skill-data?period=<day|month|year>
*
* Matches Rust: pub async fn get_skill_data()
* TODO: Implement skill aggregation service
*
* @param groupName The group name
* @param period The aggregation period (day, month, year)
* @param auth Authentication
* @return Skill data by period
*/
@GetMapping("/get-skill-data")
public ResponseEntity<List<Map<String, Object>>> getSkillData(
@PathVariable("group_name") String groupName,
@RequestParam("period") String period,
Authentication auth) {
Long groupId = (Long) auth.getPrincipal();
log.debug("Getting skill data for group_id: {}, period: {}", groupId, period);
// TODO: Implement skill aggregation service
// For now, return empty list
return ResponseEntity.ok(List.of());
}
/**
* Get collection log data.
* GET /api/group/{group_name}/collection-log
*
* Matches Rust: pub async fn collection_log()
* TODO: Implement collection log service
*
* @param groupName The group name
* @param auth Authentication
* @return Collection log data
*/
@GetMapping("/collection-log")
public ResponseEntity<List<Map<String, Object>>> getCollectionLog(
@PathVariable("group_name") String groupName,
Authentication auth) {
Long groupId = (Long) auth.getPrincipal();
log.debug("Getting collection log for group_id: {}", groupId);
// TODO: Implement collection log service
// TODO: Implement collection log service
// For now, return empty list
return ResponseEntity.ok(List.of());
}
}

View File

@@ -0,0 +1,139 @@
package com.osleague.groupironmen.controller;
import com.osleague.groupironmen.dto.AddMemberRequest;
import com.osleague.groupironmen.dto.DeleteMemberRequest;
import com.osleague.groupironmen.dto.RenameMemberRequest;
import com.osleague.groupironmen.dto.UpdateMemberRequest;
import com.osleague.groupironmen.service.MemberService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* Member management authenticated endpoints.
* All endpoints require valid token authentication.
* Matches Rust authed.rs member endpoints.
*/
@Slf4j
@RestController
@RequestMapping("/api/group/{group_name}")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
/**
* Update group member data (from RuneLite plugin).
* POST /api/group/{group_name}/update-group-member
*
* Matches Rust: pub async fn update_group_member()
*
* IMPORTANT: This is the main endpoint the RuneLite plugin calls.
* Only non-null fields in the request will be updated.
*
* @param groupName The group name (from path)
* @param request Member update data
* @param auth Authentication (contains group_id)
* @return Success response
*/
@PostMapping("/update-group-member")
public ResponseEntity<Map<String, String>> updateGroupMember(
@PathVariable("group_name") String groupName,
@Valid @RequestBody UpdateMemberRequest request,
Authentication auth) {
Long groupId = (Long) auth.getPrincipal();
log.debug("Updating member '{}' in group_id: {}", request.getName(), groupId);
memberService.updateMember(groupId, request);
return ResponseEntity.ok(Map.of("status", "success"));
}
/**
* Add a new member to the group.
* POST /api/group/{group_name}/add-group-member
*
* Matches Rust: pub async fn add_group_member()
*
* @param groupName The group name
* @param request Add member request
* @param auth Authentication
* @return Success response
*/
@PostMapping("/add-group-member")
public ResponseEntity<Map<String, String>> addGroupMember(
@PathVariable("group_name") String groupName,
@Valid @RequestBody AddMemberRequest request,
Authentication auth) {
Long groupId = (Long) auth.getPrincipal();
log.info("Adding member '{}' to group_id: {}", request.getName(), groupId);
memberService.addMember(groupId, request.getName());
return ResponseEntity.ok(Map.of("status", "success"));
}
/**
* Delete a member from the group.
* DELETE /api/group/{group_name}/delete-group-member
*
* Matches Rust: pub async fn delete_group_member()
*
* @param groupName The group name
* @param request Delete member request
* @param auth Authentication
* @return Success response
*/
@DeleteMapping("/delete-group-member")
public ResponseEntity<Map<String, String>> deleteGroupMember(
@PathVariable("group_name") String groupName,
@Valid @RequestBody DeleteMemberRequest request,
Authentication auth) {
Long groupId = (Long) auth.getPrincipal();
log.info("Deleting member '{}' from group_id: {}", request.getName(), groupId);
memberService.deleteMember(groupId, request.getName());
return ResponseEntity.ok(Map.of("status", "success"));
}
/**
* Rename a group member.
* PUT /api/group/{group_name}/rename-group-member
*
* Matches Rust: pub async fn rename_group_member()
*
* @param groupName The group name
* @param request Rename request
* @param auth Authentication
* @return Success response
*/
@PutMapping("/rename-group-member")
public ResponseEntity<Map<String, String>> renameGroupMember(
@PathVariable("group_name") String groupName,
@Valid @RequestBody RenameMemberRequest request,
Authentication auth) {
Long groupId = (Long) auth.getPrincipal();
log.info("Renaming member '{}' -> '{}' in group_id: {}",
request.getOriginalName(),
request.getNewName(),
groupId);
memberService.renameMember(groupId, request.getOriginalName(), request.getNewName());
return ResponseEntity.ok(Map.of("status", "success"));
}
}

View File

@@ -0,0 +1,112 @@
package com.osleague.groupironmen.controller;
import com.osleague.groupironmen.dto.CaptchaConfigResponse;
import com.osleague.groupironmen.dto.CreateGroupRequest;
import com.osleague.groupironmen.dto.CreateGroupResponse;
import com.osleague.groupironmen.dto.GePricesResponse;
import com.osleague.groupironmen.service.GrandExchangeService;
import com.osleague.groupironmen.service.GroupService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* Public API endpoints (no authentication required).
* Matches Rust unauthed.rs endpoints.
*/
@Slf4j
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class PublicController {
private final GroupService groupService;
private final GrandExchangeService geService;
@Value("${app.captcha.enabled}")
private boolean captchaEnabled;
@Value("${app.captcha.sitekey}")
private String captchaSitekey;
/**
* Create a new group.
* POST /api/create-group
*
* Matches Rust: pub async fn create_group()
*/
@PostMapping("/create-group")
public ResponseEntity<CreateGroupResponse> createGroup(
@Valid @RequestBody CreateGroupRequest request) {
log.info("Creating group: {}", request.getName());
// TODO: Implement captcha validation if enabled
// if (captchaEnabled && !verifyCaptcha(request.getCaptchaResponse())) {
// throw new ValidationException("captcha", "invalid");
// }
CreateGroupResponse response = groupService.createGroup(request);
log.info("Group created: {} with token (first 8 chars): {}...",
response.getName(),
response.getToken().substring(0, 8));
return ResponseEntity.ok(response);
}
/**
* Get cached Grand Exchange prices.
* GET /api/ge-prices
*
* Matches Rust: pub async fn get_ge_prices()
*/
@GetMapping("/ge-prices")
public ResponseEntity<GePricesResponse> getGePrices() {
log.debug("Fetching GE prices");
Map<String, Integer> prices = geService.getCachedPrices();
GePricesResponse response = new GePricesResponse(prices);
return ResponseEntity.ok(response);
}
/**
* Get captcha configuration.
* GET /api/captcha-enabled
*
* Matches Rust: pub async fn captcha_enabled()
*/
@GetMapping("/captcha-enabled")
public ResponseEntity<CaptchaConfigResponse> getCaptchaConfig() {
log.debug("Fetching captcha config");
CaptchaConfigResponse response = CaptchaConfigResponse.builder()
.enabled(captchaEnabled)
.sitekey(captchaSitekey)
.build();
return ResponseEntity.ok(response);
}
/**
* Get collection log info.
* GET /api/collection-log-info
*
* Matches Rust: pub async fn collection_log_info()
* TODO: Implement collection log info loading from JSON
*/
@GetMapping("/collection-log-info")
public ResponseEntity<Map<String, Object>> getCollectionLogInfo() {
log.debug("Fetching collection log info");
// TODO: Load from collection_log_info.json and return
// For now, return empty map
return ResponseEntity.ok(Map.of());
}
}

View File

@@ -0,0 +1,18 @@
package com.osleague.groupironmen.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* Request DTO for adding a new member to a group.
*/
@Data
public class AddMemberRequest {
@NotBlank(message = "Member name is required")
@Size(min = 1, max = 16, message = "Member name must be 1-16 characters")
@JsonProperty("name")
private String name;
}

View File

@@ -0,0 +1,23 @@
package com.osleague.groupironmen.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Response DTO for captcha configuration.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CaptchaConfigResponse {
@JsonProperty("enabled")
private boolean enabled;
@JsonProperty("sitekey")
private String sitekey;
}

View File

@@ -0,0 +1,33 @@
package com.osleague.groupironmen.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* Request DTO for creating a new group.
* Matches Rust CreateGroup struct.
*/
@Data
public class CreateGroupRequest {
@NotBlank(message = "Group name is required")
@Size(min = 1, max = 64, message = "Group name must be 1-64 characters")
@JsonProperty("name")
private String name;
@NotNull(message = "Member names are required")
@Size(min = 1, max = 5, message = "Group must have 1-5 members")
@JsonProperty("member_names")
private List<String> memberNames;
@JsonProperty("captcha_response")
private String captchaResponse = "";
@JsonProperty("token")
private String token; // Will be generated server-side
}

View File

@@ -0,0 +1,27 @@
package com.osleague.groupironmen.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Response DTO for group creation.
* Returns the group token and group ID.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateGroupResponse {
@JsonProperty("token")
private String token;
@JsonProperty("group_id")
private Long groupId;
@JsonProperty("name")
private String name;
}

View File

@@ -0,0 +1,16 @@
package com.osleague.groupironmen.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* Request DTO for deleting a group member.
*/
@Data
public class DeleteMemberRequest {
@NotBlank(message = "Member name is required")
@JsonProperty("name")
private String name;
}

View File

@@ -0,0 +1,18 @@
package com.osleague.groupironmen.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* Response DTO for Grand Exchange prices.
* Simple map of item_id -> price.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GePricesResponse {
private Map<String, Integer> prices; // item_id -> average price
}

View File

@@ -0,0 +1,71 @@
package com.osleague.groupironmen.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.List;
/**
* DTO for group member data.
* Matches Rust GroupMember struct.
*
* IMPORTANT: Fields are only included if non-null (skip_serializing_if in Rust).
* This enables delta updates - only changed fields are sent.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL) // Skip null fields (matches Rust behavior)
public class GroupMemberDto {
@JsonProperty("name")
private String name;
@JsonProperty("stats")
private List<Integer> stats; // 7 integers: HP, Prayer, Energy, World
@JsonProperty("coordinates")
private Object coordinates; // 3 integers: x, y, plane
@JsonProperty("skills")
private List<Integer> skills; // 24 integers
@JsonProperty("quests")
private byte[] quests; // Binary data (Vec<u8> in Rust)
@JsonProperty("inventory")
private List<Integer> inventory; // 56 integers
@JsonProperty("equipment")
private List<Integer> equipment; // 28 integers
@JsonProperty("bank")
private List<Integer> bank; // Variable length
@JsonProperty("shared_bank")
private List<Integer> sharedBank; // Variable length
@JsonProperty("rune_pouch")
private List<Integer> runePouch; // 8 integers
@JsonProperty("seed_vault")
private List<Integer> seedVault; // Variable length
@JsonProperty("deposited")
private List<Integer> deposited; // Variable length (not in DB yet)
@JsonProperty("diary_vars")
private List<Integer> diaryVars; // 62 integers
@JsonProperty("interacting")
private String interacting; // NPC name (simplified from Rust struct)
@JsonProperty("last_updated")
private Instant lastUpdated; // Timestamp of most recent update
}

View File

@@ -0,0 +1,21 @@
package com.osleague.groupironmen.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* Request DTO for renaming a group member.
* Matches Rust RenameGroupMember struct.
*/
@Data
public class RenameMemberRequest {
@NotBlank(message = "Original name is required")
@JsonProperty("original_name")
private String originalName;
@NotBlank(message = "New name is required")
@JsonProperty("new_name")
private String newName;
}

View File

@@ -0,0 +1,65 @@
package com.osleague.groupironmen.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.osleague.groupironmen.json.InteractingDeserializer;
import lombok.Data;
import java.util.List;
/**
* Request DTO for updating a group member.
* Matches the plugin's update payload.
*
* IMPORTANT: All fields are optional (nullable).
* Only provided fields will be updated in the database.
*/
@Data
public class UpdateMemberRequest {
@NotBlank(message = "Member name is required")
@JsonProperty("name")
private String name;
@JsonProperty("stats")
private List<Integer> stats;
@JsonProperty("coordinates")
private List<Integer> coordinates;
@JsonProperty("skills")
private List<Integer> skills;
@JsonProperty("quests")
private byte[] quests;
@JsonProperty("inventory")
private List<Integer> inventory;
@JsonProperty("equipment")
private List<Integer> equipment;
@JsonProperty("bank")
private List<Integer> bank;
@JsonProperty("shared_bank")
private List<Integer> sharedBank;
@JsonProperty("rune_pouch")
private List<Integer> runePouch;
@JsonProperty("seed_vault")
private List<Integer> seedVault;
@JsonProperty("deposited")
private List<Integer> deposited;
@JsonProperty("diary_vars")
private List<Integer> diaryVars;
@JsonProperty("interacting")
@JsonDeserialize(using = InteractingDeserializer.class)
private String interacting;
}

View File

@@ -0,0 +1,10 @@
package com.osleague.groupironmen.exception;
/**
* Exception thrown when attempting to create a group that already exists.
*/
public class DuplicateGroupException extends RuntimeException {
public DuplicateGroupException(String groupName) {
super(String.format("Group '%s' already exists", groupName));
}
}

View File

@@ -0,0 +1,99 @@
package com.osleague.groupironmen.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Global exception handler for Group Ironmen API.
* Catches exceptions and returns appropriate HTTP responses.
* Matches Rust error response format.
*/
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
/**
* Handle GroupNotFoundException - 404 Not Found
*/
@ExceptionHandler(GroupNotFoundException.class)
public ResponseEntity<Object> handleGroupNotFound(GroupNotFoundException ex, WebRequest request) {
log.warn("Group not found: {}", ex.getMessage());
return buildErrorResponse(ex.getMessage(), HttpStatus.NOT_FOUND, request);
}
/**
* Handle MemberNotFoundException - 404 Not Found
*/
@ExceptionHandler(MemberNotFoundException.class)
public ResponseEntity<Object> handleMemberNotFound(MemberNotFoundException ex, WebRequest request) {
log.warn("Member not found: {}", ex.getMessage());
return buildErrorResponse(ex.getMessage(), HttpStatus.NOT_FOUND, request);
}
/**
* Handle GroupFullException - 400 Bad Request
*/
@ExceptionHandler(GroupFullException.class)
public ResponseEntity<Object> handleGroupFull(GroupFullException ex, WebRequest request) {
log.warn("Group full: {}", ex.getMessage());
return buildErrorResponse(ex.getMessage(), HttpStatus.BAD_REQUEST, request);
}
/**
* Handle ValidationException - 400 Bad Request
*/
@ExceptionHandler(ValidationException.class)
public ResponseEntity<Object> handleValidation(ValidationException ex, WebRequest request) {
log.warn("Validation error: {}", ex.getMessage());
return buildErrorResponse(ex.getMessage(), HttpStatus.BAD_REQUEST, request);
}
/**
* Handle DuplicateGroupException - 409 Conflict
*/
@ExceptionHandler(DuplicateGroupException.class)
public ResponseEntity<Object> handleDuplicateGroup(DuplicateGroupException ex, WebRequest request) {
log.warn("Duplicate group: {}", ex.getMessage());
return buildErrorResponse(ex.getMessage(), HttpStatus.CONFLICT, request);
}
/**
* Handle IllegalArgumentException - 400 Bad Request
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Object> handleIllegalArgument(IllegalArgumentException ex, WebRequest request) {
log.warn("Illegal argument: {}", ex.getMessage());
return buildErrorResponse(ex.getMessage(), HttpStatus.BAD_REQUEST, request);
}
/**
* Handle generic exceptions - 500 Internal Server Error
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleGenericException(Exception ex, WebRequest request) {
log.error("Unhandled exception", ex);
return buildErrorResponse("Internal server error", HttpStatus.INTERNAL_SERVER_ERROR, request);
}
/**
* Build error response matching Rust format.
*/
private ResponseEntity<Object> buildErrorResponse(String message, HttpStatus status, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", Instant.now().toString());
body.put("status", status.value());
body.put("error", status.getReasonPhrase());
body.put("message", message);
body.put("path", request.getDescription(false).replace("uri=", ""));
return new ResponseEntity<>(body, status);
}
}

View File

@@ -0,0 +1,14 @@
package com.osleague.groupironmen.exception;
/**
* Exception thrown when attempting to add a member to a full group (max 5 members).
*/
public class GroupFullException extends RuntimeException {
public GroupFullException() {
super("Group is full. Maximum 5 members allowed.");
}
public GroupFullException(String groupName) {
super(String.format("Group '%s' is full. Maximum 5 members allowed.", groupName));
}
}

View File

@@ -0,0 +1,14 @@
package com.osleague.groupironmen.exception;
/**
* Exception thrown when a group cannot be found.
*/
public class GroupNotFoundException extends RuntimeException {
public GroupNotFoundException(String message) {
super(message);
}
public GroupNotFoundException(String groupName, String reason) {
super(String.format("Group '%s' not found: %s", groupName, reason));
}
}

View File

@@ -0,0 +1,10 @@
package com.osleague.groupironmen.exception;
/**
* Exception thrown when a member cannot be found.
*/
public class MemberNotFoundException extends RuntimeException {
public MemberNotFoundException(String memberName) {
super(String.format("Member '%s' not found", memberName));
}
}

View File

@@ -0,0 +1,14 @@
package com.osleague.groupironmen.exception;
/**
* Exception thrown when request validation fails.
*/
public class ValidationException extends RuntimeException {
public ValidationException(String message) {
super(message);
}
public ValidationException(String field, String reason) {
super(String.format("Validation failed for '%s': %s", field, reason));
}
}

View File

@@ -0,0 +1,33 @@
package com.osleague.groupironmen.json;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
/**
* Handles both string and object formats for "interacting".
* Example inputs:
* "interacting": "Goblin"
* "interacting": { "name": "Goblin", "id": 1234 }
*/
public class InteractingDeserializer extends JsonDeserializer<String> {
@Override
public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonNode node = p.readValueAsTree();
if (node.isTextual()) {
return node.asText(); // "Goblin"
}
if (node.isObject()) {
JsonNode nameNode = node.get("name");
return nameNode != null ? nameNode.asText() : node.toString();
}
return null;
}
}

View File

@@ -0,0 +1,58 @@
package com.osleague.groupironmen.model;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import java.util.ArrayList;
import java.util.List;
/**
* Group entity - represents a group ironman team.
* Matches the groupironman.groups table schema.
*/
@Entity
@Table(name = "groups", schema = "groupironman")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Group {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "group_id")
private Long groupId;
@Column(name = "group_name", nullable = false, columnDefinition = "TEXT")
private String groupName;
@Column(name = "group_token_hash", nullable = false, length = 64)
private String groupTokenHash;
@Column(name = "version")
@Builder.Default
private Integer version = 1;
@OneToMany(mappedBy = "group", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<Member> members = new ArrayList<>();
/**
* Helper method to add a member to the group.
*/
public void addMember(Member member) {
members.add(member);
member.setGroup(this);
}
/**
* Helper method to remove a member from the group.
*/
public void removeMember(Member member) {
members.remove(member);
member.setGroup(null);
}
}

View File

@@ -0,0 +1,155 @@
package com.osleague.groupironmen.model;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.List;
/**
* Member entity - represents a player in a group ironman team.
* Matches the groupironman.members table schema.
*
* Uses JSON columns for array fields (MariaDB doesn't support native arrays).
*/
@Entity
@Table(name = "members", schema = "groupironman",
uniqueConstraints = @UniqueConstraint(columnNames = {"group_id", "member_name"})
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long memberId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "group_id", nullable = false)
private Group group;
@Column(name = "member_name", nullable = false, columnDefinition = "TEXT")
private String memberName;
// Stats (7 integers: HP, Prayer, Energy, World, etc.)
@Column(name = "stats_last_update")
private Instant statsLastUpdate;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "stats", columnDefinition = "json")
private List<Integer> stats;
// Coordinates (3 integers: x, y, plane)
@Column(name = "coordinates_last_update")
private Instant coordinatesLastUpdate;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "coordinates", columnDefinition = "json")
private List<Integer> coordinates;
// Skills (24 integers)
@Column(name = "skills_last_update")
private Instant skillsLastUpdate;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "skills", columnDefinition = "json")
private List<Integer> skills;
// Quests (binary data)
@Column(name = "quests_last_update")
private Instant questsLastUpdate;
@Lob
@Column(name = "quests", columnDefinition = "BLOB")
private byte[] quests;
// Inventory (56 integers)
@Column(name = "inventory_last_update")
private Instant inventoryLastUpdate;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "inventory", columnDefinition = "json")
private List<Integer> inventory;
// Equipment (28 integers)
@Column(name = "equipment_last_update")
private Instant equipmentLastUpdate;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "equipment", columnDefinition = "json")
private List<Integer> equipment;
// Rune pouch (8 integers)
@Column(name = "rune_pouch_last_update")
private Instant runePouchLastUpdate;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "rune_pouch", columnDefinition = "json")
private List<Integer> runePouch;
// Bank (variable length)
@Column(name = "bank_last_update")
private Instant bankLastUpdate;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "bank", columnDefinition = "json")
private List<Integer> bank;
// Seed vault (variable length)
@Column(name = "seed_vault_last_update")
private Instant seedVaultLastUpdate;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "seed_vault", columnDefinition = "json")
private List<Integer> seedVault;
// Interacting NPC
@Column(name = "interacting_last_update")
private Instant interactingLastUpdate;
@Column(name = "interacting", columnDefinition = "TEXT")
private String interacting;
// Diary vars (62 integers)
@Column(name = "diary_vars_last_update")
private Instant diaryVarsLastUpdate;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "diary_vars", columnDefinition = "json")
private List<Integer> diaryVars;
@Column(name = "last_updated")
private Instant lastUpdated;
/**
* Get the most recent update timestamp across all fields.
* Matches Rust logic: GREATEST(stats_last_update, coordinates_last_update, ...)
*/
public Instant getLastUpdated() {
Instant latest = null;
Instant[] timestamps = {
statsLastUpdate, coordinatesLastUpdate, skillsLastUpdate,
questsLastUpdate, inventoryLastUpdate, equipmentLastUpdate,
bankLastUpdate, runePouchLastUpdate, interactingLastUpdate,
seedVaultLastUpdate, diaryVarsLastUpdate
};
for (Instant timestamp : timestamps) {
if (timestamp != null && (latest == null || timestamp.isAfter(latest))) {
latest = timestamp;
}
}
return latest;
}
}

View File

@@ -0,0 +1,47 @@
package com.osleague.groupironmen.repository;
import com.osleague.groupironmen.model.Group;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Repository for Group entities.
* Provides database access for group ironman teams.
*/
@Repository
public interface GroupRepository extends JpaRepository<Group, Long> {
/**
* Find a group by name.
*
* @param groupName The group name
* @return Optional containing the group if found
*/
Optional<Group> findByGroupName(String groupName);
/**
* Find group ID by name and token hash (for authentication).
* Matches Rust query: SELECT group_id FROM groupironman.groups WHERE group_token_hash=$1 AND group_name=$2
*
* @param groupName The group name
* @param groupTokenHash The hashed token
* @return Optional containing the group ID if found
*/
@Query("SELECT g.groupId FROM Group g WHERE g.groupName = :groupName AND g.groupTokenHash = :tokenHash")
Optional<Long> findGroupIdByNameAndTokenHash(
@Param("groupName") String groupName,
@Param("tokenHash") String groupTokenHash
);
/**
* Check if a group exists by name.
*
* @param groupName The group name
* @return true if group exists
*/
boolean existsByGroupName(String groupName);
}

View File

@@ -0,0 +1,90 @@
package com.osleague.groupironmen.repository;
import com.osleague.groupironmen.model.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/**
* Repository for Member entities.
* Provides database access for group ironman members.
*/
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
/**
* Find all members in a group.
*
* @param groupId The group ID
* @return List of members
*/
@Query("SELECT m FROM Member m WHERE m.group.groupId = :groupId")
List<Member> findByGroupId(@Param("groupId") Long groupId);
/**
* Find members updated after a specific timestamp (for delta updates).
* This matches the Rust logic that returns only changed fields since from_time.
*
* @param groupId The group ID
* @param fromTimestamp The timestamp to filter by
* @return List of members with any field updated after the timestamp
*/
@Query("""
SELECT m FROM Member m
WHERE m.group.groupId = :groupId
AND m.lastUpdated >= :fromTime
""")
List<Member> findByGroupIdAndUpdatedAfter(
@Param("groupId") Long groupId,
@Param("fromTime") Instant fromTimestamp
);
/**
* Find a member by group ID and member name.
*
* @param groupId The group ID
* @param memberName The member name
* @return Optional containing the member if found
*/
@Query("SELECT m FROM Member m WHERE m.group.groupId = :groupId AND m.memberName = :memberName")
Optional<Member> findByGroupIdAndMemberName(
@Param("groupId") Long groupId,
@Param("memberName") String memberName
);
/**
* Count members in a group.
* Used to enforce max 5 members per group.
*
* @param groupId The group ID
* @return Number of members
*/
@Query("SELECT COUNT(m) FROM Member m WHERE m.group.groupId = :groupId")
int countByGroupId(@Param("groupId") Long groupId);
/**
* Delete a member by group ID and member name.
*
* @param groupId The group ID
* @param memberName The member name
*/
void deleteByGroupGroupIdAndMemberName(Long groupId, String memberName);
/**
* Check if a member exists in a group.
*
* @param groupId The group ID
* @param memberName The member name
* @return true if member exists
*/
@Query("SELECT CASE WHEN COUNT(m) > 0 THEN true ELSE false END FROM Member m WHERE m.group.groupId = :groupId AND m.memberName = :memberName")
boolean existsByGroupIdAndMemberName(
@Param("groupId") Long groupId,
@Param("memberName") String memberName
);
}

View File

@@ -0,0 +1,77 @@
package com.osleague.groupironmen.security;
import org.bouncycastle.crypto.digests.Blake2bDigest;
import org.bouncycastle.util.encoders.Hex;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
/**
* Blake2 token hasher for compatibility with Rust backend.
* Uses Blake2b-256 with 2 iterations to hash group tokens.
*
* This implementation MUST match the Rust implementation exactly:
* - Uses Blake2b (not Blake2s, despite Rust using Blake2s256 which is 256-bit Blake2s)
* - 2 iterations of hashing
* - Combines token + secret + salt
* - Returns hex-encoded hash (64 characters)
*/
@Component
public class Blake2TokenHasher {
@Value("${app.security.secret}")
private String secret;
/**
* Hash a token with Blake2b-256 (2 iterations).
*
* @param token The group token (UUID format)
* @param salt The group name (used as salt)
* @return Hex-encoded hash (64 characters)
*/
public String hashToken(String token, String salt) {
if (token == null || salt == null) {
throw new IllegalArgumentException("Token and salt cannot be null");
}
// First iteration: hash(token + secret + salt)
Blake2bDigest digest = new Blake2bDigest(256); // 256-bit = 32 bytes
String input1 = token + secret + salt;
byte[] input1Bytes = input1.getBytes(StandardCharsets.UTF_8);
digest.update(input1Bytes, 0, input1Bytes.length);
byte[] hash1 = new byte[digest.getDigestSize()];
digest.doFinal(hash1, 0);
// Second iteration: hash(hash1)
digest.reset();
digest.update(hash1, 0, hash1.length);
byte[] hash2 = new byte[digest.getDigestSize()];
digest.doFinal(hash2, 0);
// Return hex-encoded (lowercase)
return Hex.toHexString(hash2).toLowerCase();
}
/**
* Verify if a token matches the stored hash.
*
* @param token The token to verify
* @param salt The group name
* @param storedHash The hash stored in database
* @return true if token is valid
*/
public boolean verifyToken(String token, String salt, String storedHash) {
if (token == null || salt == null || storedHash == null) {
return false;
}
try {
String computedHash = hashToken(token, salt);
return computedHash.equals(storedHash.toLowerCase());
} catch (Exception e) {
return false;
}
}
}

View File

@@ -0,0 +1,117 @@
package com.osleague.groupironmen.security;
import com.osleague.groupironmen.repository.GroupRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.servlet.HandlerMapping;
import java.io.IOException;
import java.util.Collections;
import java.util.Map;
/**
* Authentication filter for group ironmen API.
* Validates token from Authorization header and sets group_id in security context.
*
* Matches Rust authentication logic:
* - Extracts group_name from path variable
* - Reads Authorization header (raw token, no Bearer prefix)
* - Hashes token with Blake2
* - Queries database to verify token and get group_id
* - Sets group_id in security context for downstream use
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final Blake2TokenHasher tokenHasher;
private final GroupRepository groupRepository;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String path = request.getRequestURI();
// Only apply to /api/group/{group_name}/... endpoints
if (!path.startsWith("/api/group/")) {
filterChain.doFilter(request, response);
return;
}
try {
// === NEW: extract group_name directly from the URL path ===
// Example path: /api/group/testgroup/get-group-data
String[] parts = path.split("/");
String groupName = (parts.length >= 4) ? parts[3] : null;
if (groupName == null || groupName.isBlank()) {
log.warn("Missing group_name in path: {}", path);
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing group name from request path");
return;
}
// Skip authentication for special group "_"
if ("_".equals(groupName)) {
filterChain.doFilter(request, response);
return;
}
// Extract Authorization header
String authHeader = request.getHeader("Authorization");
if (authHeader == null || authHeader.isBlank()) {
log.warn("Missing Authorization header for group: {}", groupName);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization header missing from request");
return;
}
String token = authHeader.trim();
// Hash token with Blake2
String hashedToken = tokenHasher.hashToken(token, groupName);
// Query database to get group_id
Long groupId = groupRepository.findGroupIdByNameAndTokenHash(groupName, hashedToken)
.orElse(null);
if (groupId == null) {
log.warn("Invalid token for group: {}", groupName);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid authentication credentials");
return;
}
// Set authentication in security context
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
groupId,
null,
Collections.singletonList(new SimpleGrantedAuthority("ROLE_GROUP"))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authenticated group: {} with groupId: {}", groupName, groupId);
} catch (Exception e) {
log.error("Authentication error", e);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Authentication failed");
return;
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,100 @@
package com.osleague.groupironmen.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
/**
* Service for fetching and caching Grand Exchange prices.
* Matches Rust GE price updater logic.
*/
@Slf4j
@Service
public class GrandExchangeService {
@Value("${app.ge-prices.url}")
private String gePricesUrl;
private final WebClient webClient;
private final AtomicReference<Map<String, Integer>> cachedPrices = new AtomicReference<>(new HashMap<>());
public GrandExchangeService(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024))
.build();
}
/**
* Get cached GE prices.
* Returns cached prices (updated every 4 hours by scheduled task).
*
* @return Map of item_id -> price
*/
public Map<String, Integer> getCachedPrices() {
return new HashMap<>(cachedPrices.get());
}
/**
* Fetch and cache GE prices from RuneScape Wiki API.
* Runs every 4 hours (14400000 ms).
* Matches Rust start_ge_updater() logic.
*/
@Scheduled(fixedRate = 14400000, initialDelay = 0)
public void updatePrices() {
try {
log.info("Fetching GE prices from: {}", gePricesUrl);
// Fetch from RuneScape Wiki API
WikiPricesResponse response = webClient.get()
.uri(gePricesUrl)
.retrieve()
.bodyToMono(WikiPricesResponse.class)
.block();
if (response == null || response.data == null) {
log.warn("Failed to fetch GE prices: empty response");
return;
}
// Convert to simple map: item_id -> average(high, low)
Map<String, Integer> prices = new HashMap<>();
response.data.forEach((itemId, priceData) -> {
// Average high and low prices (matching Rust logic)
Integer high = priceData.get("high");
Integer low = priceData.get("low");
if (high != null && low != null) {
int avgPrice = (high + low) / 2;
prices.put(itemId, avgPrice);
} else if (high != null) {
prices.put(itemId, high);
} else if (low != null) {
prices.put(itemId, low);
}
});
// Update cached prices
cachedPrices.set(prices);
log.info("Updated {} GE prices", prices.size());
} catch (Exception e) {
log.error("Failed to update GE prices", e);
}
}
/**
* DTO for RuneScape Wiki API response.
* Format: { "data": { "item_id": { "high": 123, "low": 100 } } }
*/
private static class WikiPricesResponse {
public Map<String, Map<String, Integer>> data;
}
}

View File

@@ -0,0 +1,186 @@
package com.osleague.groupironmen.service;
import com.osleague.groupironmen.dto.CreateGroupRequest;
import com.osleague.groupironmen.dto.CreateGroupResponse;
import com.osleague.groupironmen.dto.GroupMemberDto;
import com.osleague.groupironmen.exception.DuplicateGroupException;
import com.osleague.groupironmen.exception.GroupNotFoundException;
import com.osleague.groupironmen.model.Group;
import com.osleague.groupironmen.model.Member;
import com.osleague.groupironmen.repository.GroupRepository;
import com.osleague.groupironmen.repository.MemberRepository;
import com.osleague.groupironmen.security.Blake2TokenHasher;
import com.osleague.groupironmen.util.ValidationUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Service for managing group ironman teams.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GroupService {
private final GroupRepository groupRepository;
private final MemberRepository memberRepository;
private final Blake2TokenHasher tokenHasher;
/**
* Create a new group with initial members.
* Matches Rust create_group() logic.
*
* @param request Create group request
* @return Group token and ID
*/
@Transactional
public CreateGroupResponse createGroup(CreateGroupRequest request) {
// Validate group name
ValidationUtils.validateGroupName(request.getName());
// Check if group already exists
if (groupRepository.existsByGroupName(request.getName())) {
throw new DuplicateGroupException(request.getName());
}
// Validate member names
for (String memberName : request.getMemberNames()) {
ValidationUtils.validateMemberName(memberName);
}
// Generate UUID token
String token = UUID.randomUUID().toString();
// Hash token with Blake2
String hashedToken = tokenHasher.hashToken(token, request.getName());
// Create group entity
Group group = Group.builder()
.groupName(request.getName())
.groupTokenHash(hashedToken)
.version(1)
.build();
// Create member entities
for (String memberName : request.getMemberNames()) {
Member member = Member.builder()
.memberName(memberName)
.group(group)
.build();
group.addMember(member);
}
// Save group (cascades to members)
Group savedGroup = groupRepository.save(group);
log.info("Created group: {} with {} members", savedGroup.getGroupName(), savedGroup.getMembers().size());
return CreateGroupResponse.builder()
.token(token) // Return plain token (not hashed)
.groupId(savedGroup.getGroupId())
.name(savedGroup.getGroupName())
.build();
}
/**
* Get group data with delta updates.
* Matches Rust get_group_data() logic.
*
* @param groupId The group ID (from authentication)
* @param fromTimestamp Only return members updated after this time (null = all)
* @return List of member DTOs
*/
@Transactional(readOnly = true)
public List<GroupMemberDto> getGroupData(Long groupId, Instant fromTimestamp) {
List<Member> members;
if (fromTimestamp != null) {
// Delta update: only members with changes since fromTimestamp
members = memberRepository.findByGroupIdAndUpdatedAfter(groupId, fromTimestamp);
} else {
// Full update: all members
members = memberRepository.findByGroupId(groupId);
}
return members.stream()
.map(this::convertToDto)
.collect(Collectors.toList());
}
/**
* Check if a member is in a group.
*/
@Transactional(readOnly = true)
public boolean isMemberInGroup(Long groupId, String memberName) {
return memberRepository.existsByGroupIdAndMemberName(groupId, memberName);
}
/**
* Convert Member entity to DTO.
* IMPORTANT: Only include fields that were updated after fromTimestamp.
* This matches Rust CASE WHEN logic for delta updates.
*/
private GroupMemberDto convertToDto(Member member) {
Instant lastUpdated = member.getLastUpdated();
Map<String, Integer> coordinatesMap = null;
List<Integer> coords = member.getCoordinates();
if (coords != null && coords.size() >= 3) {
coordinatesMap = Map.of(
"x", coords.get(0),
"y", coords.get(1),
"plane", coords.get(2)
);
}
// Note: For proper delta updates, we would need to compare each field's
// last_update timestamp against fromTimestamp. For now, we include all fields.
// TODO: Implement per-field delta logic matching Rust CASE WHEN queries
return GroupMemberDto.builder()
.name(member.getMemberName())
.stats(member.getStats())
.coordinates(coordinatesMap)
.skills(member.getSkills())
.quests(padQuestData(member.getQuests()))
.inventory(member.getInventory())
.equipment(member.getEquipment())
.bank(member.getBank())
.sharedBank(null) // TODO: Handle shared bank separately
.runePouch(member.getRunePouch())
.seedVault(member.getSeedVault())
.deposited(null) // Not implemented in DB yet
.diaryVars(member.getDiaryVars())
.interacting(member.getInteracting())
.lastUpdated(lastUpdated)
.build();
}
/**
* Get group by name (for admin purposes).
*/
@Transactional(readOnly = true)
public Group getGroupByName(String groupName) {
return groupRepository.findByGroupName(groupName)
.orElseThrow(() -> new GroupNotFoundException(groupName, "not found"));
}
private static byte[] padQuestData(byte[] questData) {
if (questData == null) {
return new byte[440]; // ensure full quest array
}
if (questData.length < 440) {
byte[] padded = new byte[440];
System.arraycopy(questData, 0, padded, 0, questData.length);
return padded;
}
return questData;
}
}

View File

@@ -0,0 +1,178 @@
package com.osleague.groupironmen.service;
import com.osleague.groupironmen.dto.UpdateMemberRequest;
import com.osleague.groupironmen.exception.GroupFullException;
import com.osleague.groupironmen.exception.MemberNotFoundException;
import com.osleague.groupironmen.model.Group;
import com.osleague.groupironmen.model.Member;
import com.osleague.groupironmen.repository.GroupRepository;
import com.osleague.groupironmen.repository.MemberRepository;
import com.osleague.groupironmen.util.ValidationUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Objects;
/**
* Service for managing group members.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final GroupRepository groupRepository;
private static final int MAX_GROUP_SIZE = 5;
/**
* Update member data from RuneLite plugin.
* Matches Rust update_group_member() logic.
*
* Only updates provided (non-null) fields, and sets
* per-field *_last_update timestamps. The overall lastUpdated
* field only changes if at least one value actually changed.
*/
@Transactional
public void updateMember(Long groupId, UpdateMemberRequest request) {
ValidationUtils.validateMemberName(request.getName());
Member member = memberRepository.findByGroupIdAndMemberName(groupId, request.getName())
.orElseThrow(() -> new MemberNotFoundException(request.getName()));
Instant now = Instant.now();
boolean changed = false;
if (request.getStats() != null) {
member.setStats(request.getStats());
member.setStatsLastUpdate(now);
changed = true;
}
if (request.getCoordinates() != null) {
member.setCoordinates(request.getCoordinates());
member.setCoordinatesLastUpdate(now);
changed = true;
}
if (request.getSkills() != null) {
member.setSkills(request.getSkills());
member.setSkillsLastUpdate(now);
changed = true;
}
if (request.getQuests() != null) {
member.setQuests(request.getQuests());
member.setQuestsLastUpdate(now);
changed = true;
}
if (request.getInventory() != null) {
member.setInventory(request.getInventory());
member.setInventoryLastUpdate(now);
changed = true;
}
if (request.getEquipment() != null) {
member.setEquipment(request.getEquipment());
member.setEquipmentLastUpdate(now);
changed = true;
}
if (request.getRunePouch() != null) {
member.setRunePouch(request.getRunePouch());
member.setRunePouchLastUpdate(now);
changed = true;
}
if (request.getBank() != null) {
member.setBank(request.getBank());
member.setBankLastUpdate(now);
changed = true;
}
if (request.getSeedVault() != null) {
member.setSeedVault(request.getSeedVault());
member.setSeedVaultLastUpdate(now);
changed = true;
}
if (request.getDiaryVars() != null) {
member.setDiaryVars(request.getDiaryVars());
member.setDiaryVarsLastUpdate(now);
changed = true;
}
if (request.getInteracting() != null) {
member.setInteracting(request.getInteracting());
member.setInteractingLastUpdate(now);
changed = true;
}
if (changed) {
member.setLastUpdated(now);
memberRepository.save(member);
}
}
// === Add / Delete / Rename methods remain unchanged ===
@Transactional
public void addMember(Long groupId, String memberName) {
ValidationUtils.validateMemberName(memberName);
int currentSize = memberRepository.countByGroupId(groupId);
if (currentSize >= MAX_GROUP_SIZE) {
throw new GroupFullException();
}
if (memberRepository.existsByGroupIdAndMemberName(groupId, memberName)) {
throw new IllegalArgumentException("Member already exists in group");
}
Group group = groupRepository.findById(groupId)
.orElseThrow(() -> new IllegalArgumentException("Group not found"));
Member member = Member.builder()
.memberName(memberName)
.group(group)
.build();
memberRepository.save(member);
log.info("Added member: {} to group: {}", memberName, groupId);
}
@Transactional
public void deleteMember(Long groupId, String memberName) {
if (!memberRepository.existsByGroupIdAndMemberName(groupId, memberName)) {
throw new MemberNotFoundException(memberName);
}
memberRepository.deleteByGroupGroupIdAndMemberName(groupId, memberName);
log.info("Deleted member: {} from group: {}", memberName, groupId);
}
@Transactional
public void renameMember(Long groupId, String originalName, String newName) {
ValidationUtils.validateMemberName(originalName);
ValidationUtils.validateMemberName(newName);
Member member = memberRepository.findByGroupIdAndMemberName(groupId, originalName)
.orElseThrow(() -> new MemberNotFoundException(originalName));
if (memberRepository.existsByGroupIdAndMemberName(groupId, newName)) {
throw new IllegalArgumentException("Member with new name already exists");
}
member.setMemberName(newName);
memberRepository.save(member);
log.info("Renamed member: {} -> {} in group: {}", originalName, newName, groupId);
}
}

View File

@@ -0,0 +1,84 @@
package com.osleague.groupironmen.util;
import com.osleague.groupironmen.exception.ValidationException;
import java.util.List;
import java.util.regex.Pattern;
/**
* Validation utilities for Group Ironmen data.
* Matches Rust validation logic.
*/
public class ValidationUtils {
// Matches Rust regex: [^A-Za-z 0-9-_] (no match = valid)
private static final Pattern INVALID_NAME_PATTERN = Pattern.compile("[^A-Za-z 0-9\\-_]");
private static final String RESERVED_NAME = "@SHARED";
/**
* Validate member name.
* Rust logic: valid_name() in validators.rs
*
* Rules:
* - 1-16 characters
* - Only letters, numbers, spaces, hyphens, underscores
* - Cannot be all whitespace
* - Cannot be "@SHARED" (reserved)
* - ASCII only
*/
public static void validateMemberName(String name) {
if (name == null || name.isEmpty()) {
throw new ValidationException("name", "cannot be empty");
}
if (name.length() > 16) {
throw new ValidationException("name", "must be 16 characters or less");
}
if (name.isBlank()) {
throw new ValidationException("name", "cannot be all whitespace");
}
if (INVALID_NAME_PATTERN.matcher(name).find()) {
throw new ValidationException("name", "contains invalid characters (only A-Z, a-z, 0-9, space, -, _ allowed)");
}
if (RESERVED_NAME.equalsIgnoreCase(name)) {
throw new ValidationException("name", "@SHARED is a reserved name");
}
}
/**
* Validate array field lengths.
* Matches Rust validate_member_prop_length() logic.
*/
public static void validateArrayLength(String fieldName, List<Integer> array, int expectedLength) {
if (array != null && array.size() != expectedLength) {
throw new ValidationException(fieldName,
String.format("expected %d elements, got %d", expectedLength, array.size()));
}
}
/**
* Validate array field length with range.
*/
public static void validateArrayLengthRange(String fieldName, List<Integer> array, int min, int max) {
if (array != null && (array.size() < min || array.size() > max)) {
throw new ValidationException(fieldName,
String.format("expected %d-%d elements, got %d", min, max, array.size()));
}
}
/**
* Validate group name.
*/
public static void validateGroupName(String name) {
if (name == null || name.isEmpty()) {
throw new ValidationException("group name", "cannot be empty");
}
if (name.length() > 64) {
throw new ValidationException("group name", "must be 64 characters or less");
}
}
}

View File

@@ -0,0 +1,71 @@
spring:
application:
name: group-ironmen-backend
datasource:
url: jdbc:mariadb://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:groupironman}?useSSL=false&allowPublicKeyRetrieval=true
username: ${DB_USER:root}
password: ${DB_PASSWORD:password}
driver-class-name: org.mariadb.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
jpa:
hibernate:
ddl-auto: none
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.MariaDBDialect
format_sql: true
jdbc:
batch_size: 20
order_inserts: true
order_updates: true
open-in-view: false
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
schemas: groupironman
jackson:
serialization:
write-dates-as-timestamps: false
deserialization:
fail-on-unknown-properties: false
server:
port: ${SERVER_PORT:8080}
compression:
enabled: true
mime-types: application/json,application/xml,text/html,text/xml,text/plain
error:
include-message: always
include-binding-errors: always
app:
security:
secret: ${BACKEND_SECRET:changeme_secret_key_for_production}
captcha:
enabled: ${CAPTCHA_ENABLED:false}
sitekey: ${CAPTCHA_SITEKEY:}
secret: ${CAPTCHA_SECRET:}
ge-prices:
url: https://prices.runescape.wiki/api/v1/osrs/latest
cache-duration-hours: 4
cors:
allowed-origins: ${CORS_ORIGINS:http://localhost:3000,http://localhost:4000}
logging:
level:
root: INFO
com.osleague: DEBUG
org.springframework.web: INFO
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE

View File

@@ -0,0 +1,150 @@
-- Create groupironman database schema
CREATE DATABASE IF NOT EXISTS groupironman;
USE groupironman;
-- Groups table
CREATE TABLE IF NOT EXISTS `groups` (
`group_id` BIGINT AUTO_INCREMENT UNIQUE,
`group_name` TEXT NOT NULL,
`group_token_hash` VARCHAR(64) NOT NULL,
`version` INT DEFAULT 1,
PRIMARY KEY (`group_name`(255), `group_token_hash`),
KEY `idx_group_id` (`group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Members table
CREATE TABLE IF NOT EXISTS `members` (
`member_id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`group_id` BIGINT NOT NULL,
`member_name` TEXT NOT NULL,
-- Stats (HP, Prayer, Energy, World, etc.)
`stats_last_update` TIMESTAMP NULL,
`stats` JSON NULL COMMENT 'Array of 7 integers',
-- Coordinates (x, y, plane)
`coordinates_last_update` TIMESTAMP NULL,
`coordinates` JSON NULL COMMENT 'Array of 3 integers',
-- Skills (24 skills)
`skills_last_update` TIMESTAMP NULL,
`skills` JSON NULL COMMENT 'Array of 24 integers',
-- Quests (binary data)
`quests_last_update` TIMESTAMP NULL,
`quests` BLOB NULL,
-- Inventory (56 items)
`inventory_last_update` TIMESTAMP NULL,
`inventory` JSON NULL COMMENT 'Array of 56 integers',
-- Equipment (28 slots)
`equipment_last_update` TIMESTAMP NULL,
`equipment` JSON NULL COMMENT 'Array of 28 integers',
-- Rune pouch (8 runes)
`rune_pouch_last_update` TIMESTAMP NULL,
`rune_pouch` JSON NULL COMMENT 'Array of 8 integers',
-- Bank (variable length)
`bank_last_update` TIMESTAMP NULL,
`bank` JSON NULL COMMENT 'Array of integers',
-- Seed vault (variable length)
`seed_vault_last_update` TIMESTAMP NULL,
`seed_vault` JSON NULL COMMENT 'Array of integers',
-- Interacting NPC
`interacting_last_update` TIMESTAMP NULL,
`interacting` TEXT NULL,
-- Diary vars (62 integers)
`diary_vars_last_update` TIMESTAMP NULL,
`diary_vars` JSON NULL COMMENT 'Array of 62 integers',
CONSTRAINT `fk_members_group` FOREIGN KEY (`group_id`) REFERENCES `groups`(`group_id`) ON DELETE CASCADE,
UNIQUE KEY `idx_members_group_name` (`group_id`, `member_name`(255))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Skills aggregation tables (day, month, year)
CREATE TABLE IF NOT EXISTS `skills_day` (
`member_id` BIGINT NOT NULL,
`time` TIMESTAMP NOT NULL,
`skills` JSON NOT NULL COMMENT 'Array of 24 integers',
PRIMARY KEY (`member_id`, `time`),
CONSTRAINT `fk_skills_day_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`member_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `skills_month` (
`member_id` BIGINT NOT NULL,
`time` TIMESTAMP NOT NULL,
`skills` JSON NOT NULL COMMENT 'Array of 24 integers',
PRIMARY KEY (`member_id`, `time`),
CONSTRAINT `fk_skills_month_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`member_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `skills_year` (
`member_id` BIGINT NOT NULL,
`time` TIMESTAMP NOT NULL,
`skills` JSON NOT NULL COMMENT 'Array of 24 integers',
PRIMARY KEY (`member_id`, `time`),
CONSTRAINT `fk_skills_year_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`member_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Aggregation tracking
CREATE TABLE IF NOT EXISTS `aggregation_info` (
`type` VARCHAR(50) PRIMARY KEY,
`last_aggregation` TIMESTAMP NOT NULL DEFAULT '2000-01-01 00:00:00'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `aggregation_info` (`type`) VALUES ('skills')
ON DUPLICATE KEY UPDATE `type` = `type`;
-- Collection log tables
CREATE TABLE IF NOT EXISTS `collection_tab` (
`tab_id` SMALLINT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `collection_tab` (`tab_id`, `name`) VALUES
(0, 'Bosses'),
(1, 'Raids'),
(2, 'Clues'),
(3, 'Minigames'),
(4, 'Other')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
CREATE TABLE IF NOT EXISTS `collection_page` (
`page_id` SMALLINT AUTO_INCREMENT PRIMARY KEY,
`tab_id` SMALLINT NOT NULL,
`page_name` VARCHAR(255) NOT NULL,
UNIQUE KEY `idx_tab_page` (`tab_id`, `page_name`),
CONSTRAINT `fk_collection_page_tab` FOREIGN KEY (`tab_id`) REFERENCES `collection_tab`(`tab_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `collection_log` (
`member_id` BIGINT NOT NULL,
`page_id` SMALLINT NOT NULL,
`items` JSON NULL COMMENT 'Array of item IDs',
`counts` JSON NULL COMMENT 'Array of completion counts',
`last_updated` TIMESTAMP NULL,
PRIMARY KEY (`member_id`, `page_id`),
CONSTRAINT `fk_collection_log_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`member_id`) ON DELETE CASCADE,
CONSTRAINT `fk_collection_log_page` FOREIGN KEY (`page_id`) REFERENCES `collection_page`(`page_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `collection_log_new` (
`member_id` BIGINT NOT NULL,
`page_id` SMALLINT NOT NULL,
`new_items` JSON NULL COMMENT 'Array of new item IDs',
`last_updated` TIMESTAMP NULL,
PRIMARY KEY (`member_id`, `page_id`),
CONSTRAINT `fk_collection_log_new_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`member_id`) ON DELETE CASCADE,
CONSTRAINT `fk_collection_log_new_page` FOREIGN KEY (`page_id`) REFERENCES `collection_page`(`page_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Migrations tracking
CREATE TABLE IF NOT EXISTS `migrations` (
`name` VARCHAR(255) PRIMARY KEY,
`date` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;