initial testing
Some checks failed
Deploy App / docker (ubuntu-latest, 2.44.0, 17, 3.8.5) (push) Failing after 1m11s

This commit is contained in:
Lee
2024-04-25 05:05:10 +01:00
commit 4582be43b3
34 changed files with 2188 additions and 0 deletions

View File

@ -0,0 +1,34 @@
package cc.fascinated.backend;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
@Log4j2(topic = "Main")
@SpringBootApplication
public class Main {
public static Gson GSON = new GsonBuilder().create();
@SneakyThrows
public static void main(String[] args) {
File config = new File("application.yml");
if (!config.exists()) { // Saving the default config if it doesn't exist locally
Files.copy(Objects.requireNonNull(Main.class.getResourceAsStream("/application.yml")), config.toPath(), StandardCopyOption.REPLACE_EXISTING);
log.info("Saved the default configuration to '{}', please re-launch the application", // Log the default config being saved
config.getAbsolutePath()
);
return;
}
log.info("Found configuration at '{}'", config.getAbsolutePath()); // Log the found config
SpringApplication.run(Main.class, args); // Start the application
}
}

View File

@ -0,0 +1,23 @@
package cc.fascinated.backend.common;
import lombok.experimental.UtilityClass;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.Date;
@UtilityClass
public class DateUtils {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_INSTANT;
/**
* Gets the date from a string.
*
* @param date The date string.
* @return The date.
*/
public static Date getDateFromString(String date) {
return Date.from(Instant.from(FORMATTER.parse(date)));
}
}

View File

@ -0,0 +1,42 @@
package cc.fascinated.backend.common;
import jakarta.servlet.http.HttpServletRequest;
import lombok.experimental.UtilityClass;
@UtilityClass
public class IPUtils {
/**
* The headers that contain the IP.
*/
private static final String[] IP_HEADERS = new String[] {
"CF-Connecting-IP",
"X-Forwarded-For"
};
/**
* Get the real IP from the given request.
*
* @param request the request
* @return the real IP
*/
public static String getRealIp(HttpServletRequest request) {
String ip = request.getRemoteAddr();
for (String headerName : IP_HEADERS) {
String header = request.getHeader(headerName);
if (header == null) {
continue;
}
if (!header.contains(",")) { // Handle single IP
ip = header;
break;
}
// Handle multiple IPs
String[] ips = header.split(",");
for (String ipHeader : ips) {
ip = ipHeader;
break;
}
}
return ip;
}
}

View File

@ -0,0 +1,19 @@
package cc.fascinated.backend.common;
public class Timer {
/**
* Schedules a task to run after a delay.
*
* @param runnable the task to run
* @param delay the delay before the task runs
*/
public static void scheduleRepeating(Runnable runnable, long delay, long period) {
new java.util.Timer().scheduleAtFixedRate(new java.util.TimerTask() {
@Override
public void run() {
runnable.run();
}
}, delay, period);
}
}

View File

@ -0,0 +1,77 @@
package cc.fascinated.backend.common;
import cc.fascinated.backend.exception.impl.RateLimitException;
import lombok.experimental.UtilityClass;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
@UtilityClass
public class WebRequest {
/**
* The web client.
*/
private static final RestClient CLIENT;
static {
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();
requestFactory.setConnectTimeout(2500); // 2.5 seconds
CLIENT = RestClient.builder()
.requestFactory(requestFactory)
.build();
}
/**
* Gets a response from the given URL.
*
* @param url the url
* @return the response
* @param <T> the type of the response
*/
public static <T> T getAsEntity(String url, Class<T> clazz) throws RateLimitException {
ResponseEntity<T> responseEntity = CLIENT.get()
.uri(url)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error
.toEntity(clazz);
if (responseEntity.getStatusCode().isError()) {
return null;
}
if (responseEntity.getStatusCode().isSameCodeAs(HttpStatus.TOO_MANY_REQUESTS)) {
throw new RateLimitException("Rate limit reached");
}
return responseEntity.getBody();
}
/**
* Gets a response from the given URL.
*
* @param url the url
* @return the response
*/
public static ResponseEntity<?> get(String url, Class<?> clazz) {
return CLIENT.get()
.uri(url)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error
.toEntity(clazz);
}
/**
* Gets a response from the given URL.
*
* @param url the url
* @return the response
*/
public static ResponseEntity<?> head(String url, Class<?> clazz) {
return CLIENT.head()
.uri(url)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error
.toEntity(clazz);
}
}

View File

@ -0,0 +1,25 @@
package cc.fascinated.backend.config;
import lombok.NonNull;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class Config {
@Bean
public WebMvcConfigurer configureCors() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(@NonNull CorsRegistry registry) {
// Allow all origins to access the API
registry.addMapping("/**")
.allowedOrigins("*") // Allow all origins
.allowedMethods("*") // Allow all methods
.allowedHeaders("*"); // Allow all headers
}
};
}
}

View File

@ -0,0 +1,37 @@
package cc.fascinated.backend.controller;
import cc.fascinated.backend.model.account.Account;
import cc.fascinated.backend.service.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping(value = "/")
public class AccountController {
private final AccountService accountService;
@Autowired
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
@GetMapping(value = "/")
public ResponseEntity<?> home() {
return ResponseEntity.ok(Map.of(
"status", "OK"
));
}
@GetMapping(value = "/account/{id}")
public ResponseEntity<?> getAccount(@PathVariable String id) {
Account account = accountService.getAccount(id);
return ResponseEntity.ok(account);
}
}

View File

@ -0,0 +1,45 @@
package cc.fascinated.backend.exception;
import cc.fascinated.backend.model.response.ErrorResponse;
import io.micrometer.common.lang.NonNull;
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.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.resource.NoResourceFoundException;
@ControllerAdvice
public final class ExceptionControllerAdvice {
/**
* Handle a raised exception.
*
* @param ex the raised exception
* @return the error response
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleException(@NonNull Exception ex) {
HttpStatus status = null; // Get the HTTP status
if (ex instanceof NoResourceFoundException) { // Not found
status = HttpStatus.NOT_FOUND;
} else if (ex instanceof UnsupportedOperationException) { // Not implemented
status = HttpStatus.NOT_IMPLEMENTED;
}
if (ex.getClass().isAnnotationPresent(ResponseStatus.class)) { // Get from the @ResponseStatus annotation
status = ex.getClass().getAnnotation(ResponseStatus.class).value();
}
String message = ex.getLocalizedMessage(); // Get the error message
if (message == null) { // Fallback
message = "An internal error has occurred.";
}
// Print the stack trace if no response status is present
if (status == null) {
ex.printStackTrace();
}
if (status == null) { // Fallback to 500
status = HttpStatus.INTERNAL_SERVER_ERROR;
}
return new ResponseEntity<>(new ErrorResponse(status, message), status);
}
}

View File

@ -0,0 +1,9 @@
package cc.fascinated.backend.exception.impl;
import lombok.experimental.StandardException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@StandardException
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException { }

View File

@ -0,0 +1,12 @@
package cc.fascinated.backend.exception.impl;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public class RateLimitException extends RuntimeException {
public RateLimitException(String message) {
super(message);
}
}

View File

@ -0,0 +1,9 @@
package cc.fascinated.backend.exception.impl;
import lombok.experimental.StandardException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@StandardException
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException { }

View File

@ -0,0 +1,54 @@
package cc.fascinated.backend.log;
import cc.fascinated.backend.common.IPUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
@ControllerAdvice
@Slf4j(topic = "Req Transaction")
public class TransactionLogger implements ResponseBodyAdvice<Object> {
@Override
public Object beforeBodyWrite(Object body, @NonNull MethodParameter returnType, @NonNull MediaType selectedContentType,
@NonNull Class<? extends HttpMessageConverter<?>> selectedConverterType, @NonNull ServerHttpRequest rawRequest,
@NonNull ServerHttpResponse rawResponse) {
HttpServletRequest request = ((ServletServerHttpRequest) rawRequest).getServletRequest();
// Get the request ip ip
String ip = IPUtils.getRealIp(request);
// Getting params
Map<String, String> params = new HashMap<>();
for (Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
params.put(entry.getKey(), Arrays.toString(entry.getValue()));
}
// Logging the request
log.info(String.format("[Req] %s | %s | '%s', params=%s",
request.getMethod(),
ip,
request.getRequestURI(),
params
));
return body;
}
@Override
public boolean supports(@NonNull MethodParameter returnType, @NonNull Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
}

View File

@ -0,0 +1,213 @@
package cc.fascinated.backend.model.account;
import cc.fascinated.backend.common.DateUtils;
import cc.fascinated.backend.model.token.ScoreSaberAccountToken;
import io.micrometer.common.lang.NonNull;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import lombok.SneakyThrows;
import org.springframework.data.annotation.Id;
import java.util.Date;
@AllArgsConstructor
@Getter @Setter
public class Account {
/**
* The id for this ScoreSaber account.
*/
@Id @NonNull
private final String id;
/**
* The name for this account.
*/
private String name;
/**
* The profile picture for this account.
*/
private String profilePicture;
/**
* The bio for this account.
*/
private Bio bio;
/**
* The country for this account.
*/
private String country;
/**
* The PP for this account.
*/
private double performancePoints;
/**
* The rank for this account.
*/
private int rank;
/**
* The country rank for this account.
*/
private int countryRank;
/**
* The role for this account.
* todo: make this an enum
*/
private String role;
/**
* The badges for this account.
*/
private Badge[] badges;
/**
* The history of the rank for this account.
*/
private int[] rankHistory;
/**
* The permissions for this account.
*/
private int permission;
/**
* The banned status for this account.
*/
private boolean banned;
/**
* The inactive status for this account.
*/
private boolean inactive;
/**
* The score stats for this account.
*/
private ScoreStats scoreStats;
/**
* When the account joined ScoreSaber.
*/
private Date firstSeen;
/**
* Gets the account from the given token.
*
* @param token The token.
* @return The account.
*/
@SneakyThrows
public static Account fromToken(ScoreSaberAccountToken token) {
int[] rankHistory = new int[token.getHistories().split(",").length];
for (int i = 0; i < rankHistory.length; i++) {
rankHistory[i] = Integer.parseInt(token.getHistories().split(",")[i]);
}
// Convert the token to an account.
return new Account(
token.getId(),
token.getName(),
token.getProfilePicture(),
Bio.fromRaw(token.getBio()),
token.getCountry(),
token.getPp(),
token.getRank(),
token.getCountryRank(),
token.getRole(),
token.getBadges(),
rankHistory,
token.getPermissions(),
token.isBanned(),
token.isInactive(),
token.getScoreStats(),
DateUtils.getDateFromString(token.getFirstSeen())
);
}
/**
* The bio for this account.
*/
@AllArgsConstructor @Getter
public static class Bio {
/**
* The raw bio.
*/
private String[] raw;
/**
* The clean bio with no HTML tags.
*/
private String[] clean;
/**
* Gets the bio from the raw string.
*
* @param raw The raw bio.
* @return The bio.
*/
public static Bio fromRaw(String raw) {
return new Bio(
raw.split("\n"),
raw.replaceAll("<[^>]*>", "").split("\n")
);
}
}
/**
* The badge for this account.
*/
@AllArgsConstructor @Getter
public static class Badge {
/**
* The image for this badge.
*/
private String image;
/**
* The description for this badge.
*/
private String description;
}
/**
* The score stats for this account.
*/
@AllArgsConstructor @Getter
public static class ScoreStats {
/**
* The total score for this account.
*/
private long totalScore;
/**
* The total ranked score for this account.
*/
private long totalRankedScore;
/**
* The average ranked accuracy for this account.
*/
private double averageRankedAccuracy;
/**
* The total play count for this account.
*/
private int totalPlayCount;
/**
* The ranked play count for this account.
*/
private int rankedPlayCount;
/**
* The replays watched for this account.
*/
private int replaysWatched;
}
}

View File

@ -0,0 +1,193 @@
package cc.fascinated.backend.model.leaderboard;
import cc.fascinated.backend.common.DateUtils;
import cc.fascinated.backend.model.score.Score;
import cc.fascinated.backend.model.token.ScoreSaberLeaderboardToken;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Date;
/**
* A leaderboard for a song.
*/
@AllArgsConstructor @Getter
public class Leaderboard {
/**
* The ID of the leaderboard.
*/
private final String id;
/**
* The hash of the song.
*/
private final String songHash;
/**
* The name of the song.
*/
private String songName;
/**
* The sub name of the song.
*/
private String songSubName;
/**
* The author of the song.
*/
private String songAuthorName;
/**
* The mapper of the song.
*/
private String levelAuthorName;
/**
* The difficulty of the song.
*/
private Difficulty difficulty;
/**
* The maximum score of the song.
*/
private int maxScore;
/**
* The date the leaderboard was created.
*/
private Date createdDate;
/**
* The date the song was ranked.
*/
private Date rankedDate;
/**
* The date the song was qualified.
*/
private Date qualifiedDate;
/**
* The date the song's status was changed to loved.
*/
private Date lovedDate;
/**
* Whether this leaderboard is ranked.
*/
private boolean ranked;
/**
* Whether this leaderboard is qualified to be ranked.
*/
private boolean qualified;
/**
* Whether this leaderboard is in a loved state.
*/
private boolean loved;
/**
* The maximum PP for this leaderboard.
*/
private int maxPP;
/**
* The star rating for this leaderboard.
*/
private double stars;
/**
* The amount of plays for this leaderboard.
*/
private int plays;
/**
* The amount of daily plays for this leaderboard.
*/
private int dailyPlays;
/**
* Whether this leaderboard has positive modifiers.
*/
private boolean positiveModifiers;
/**
* The cover image for this leaderboard.
*/
private String coverImage;
/**
* Gets the leaderboard from a leaderboard token.
*
* @param token The token.
* @return The leaderboard.
*/
public static Leaderboard fromToken(ScoreSaberLeaderboardToken token) {
return new Leaderboard(
token.getId(),
token.getSongHash(),
token.getSongName(),
token.getSongSubName(),
token.getSongAuthorName(),
token.getLevelAuthorName(),
Difficulty.fromToken(token.getDifficulty()),
token.getMaxScore(),
token.getCreatedDate() == null ? null : DateUtils.getDateFromString(token.getCreatedDate()),
token.getRankedDate() == null ? null : DateUtils.getDateFromString(token.getRankedDate()),
token.getQualifiedDate() == null ? null : DateUtils.getDateFromString(token.getQualifiedDate()),
token.getLovedDate() == null ? null : DateUtils.getDateFromString(token.getLovedDate()),
token.isRanked(),
token.isQualified(),
token.isLoved(),
token.getMaxPP(),
token.getStars(),
token.getPlays(),
token.getDailyPlays(),
token.isPositiveModifiers(),
token.getCoverImage()
);
}
/**
* A difficulty for a leaderboard.
*/
@AllArgsConstructor @Getter
public static class Difficulty {
/**
* The ID of the difficulty.
*/
private final int id;
/**
* The name of the difficulty.
*/
private final Score.Difficulty difficulty;
/**
* The raw name of the difficulty.
*/
private final String rawDifficulty;
/**
* The gamemode of the difficulty.
*/
private final String gamemode;
/**
* Gets the difficulty from a token.
*
* @param token The token.
* @return The difficulty.
*/
public static Difficulty fromToken(ScoreSaberLeaderboardToken.Difficulty token) {
return new Difficulty(
token.getLeaderboardId(),
Score.Difficulty.fromId(token.getDifficulty()),
token.getDifficultyRaw(),
token.getGameMode()
);
}
}
}

View File

@ -0,0 +1,40 @@
package cc.fascinated.backend.model.response;
import io.micrometer.common.lang.NonNull;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import org.springframework.http.HttpStatus;
import java.util.Date;
@Getter @ToString @EqualsAndHashCode
public class ErrorResponse {
/**
* The status code of this error.
*/
@NonNull
private final HttpStatus status;
/**
* The HTTP code of this error.
*/
private final int code;
/**
* The message of this error.
*/
@NonNull private final String message;
/**
* The timestamp this error occurred.
*/
@NonNull private final Date timestamp;
public ErrorResponse(@NonNull HttpStatus status, @NonNull String message) {
this.status = status;
code = status.value();
this.message = message;
timestamp = new Date();
}
}

View File

@ -0,0 +1,200 @@
package cc.fascinated.backend.model.score;
import cc.fascinated.backend.common.DateUtils;
import cc.fascinated.backend.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.backend.model.token.ScoreSaberPlayerScoreToken;
import cc.fascinated.backend.model.token.ScoreSaberScoreToken;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@AllArgsConstructor @Getter @Setter
public class Score {
/**
* The id for this score.
*/
@Id
private String id;
/**
* The rank of this score.
*/
private int rank;
/**
* The base score for this score.
*/
private int baseScore;
/**
* The modified score for this score.
*/
private int modifiedScore;
/**
* The PP for this score.
*/
private double pp;
/**
* The weight for this score.
*/
private int weight;
/**
* The modifiers for this score.
*/
private List<String> modifiers;
/**
* The multiplier for this score.
*/
private int multiplier;
/**
* How many bad cuts this score has.
*/
private int badCuts;
/**
* How many misses this score has.
*/
private int missedNotes;
/**
* The maximum combo for this score.
*/
private int maxCombo;
/**
* Whether this score was a full combo.
*/
private boolean fullCombo;
/**
* The HMD that was used to set this score.
*/
private int hmd;
/**
* The time set for this score.
*/
private Date timeSet;
/**
* Whether this score has a replay.
*/
private boolean hasReplay;
/**
* The full HMD name that was used to set this score.
*/
private String deviceHmd;
/**
* The controller that was used on the left hand.
*/
private String deviceControllerLeft;
/**
* The controller that was used on the right hand.
*/
private String deviceControllerRight;
/**
* The previous scores for this score.
*/
private List<Score> previousScores;
/**
* The account who set this score.
*/
private String accountId;
/**
* The leaderboard id for this score.
*/
private String leaderboardId;
/**
* The difficulty this score was set on.
*/
private Difficulty difficulty;
/**
* Gets a score from the given token.
*
* @param token The token.
* @return The score.
*/
public static Score fromToken(String playerId, ScoreSaberPlayerScoreToken token) {
ScoreSaberScoreToken score = token.getScore();
ScoreSaberLeaderboardToken leaderboard = token.getLeaderboard();
List<String> modifiers = new ArrayList<>(List.of(score.getModifiers().split(",")));
// If the token's modifiers aren't a list, add the only modifier.
if (modifiers.isEmpty() && !score.getModifiers().isEmpty()) {
modifiers.add(score.getModifiers());
}
// Return the score.
return new Score(
score.getId(),
score.getRank(),
score.getBaseScore(),
score.getModifiedScore(),
score.getPp(),
score.getWeight(),
modifiers,
score.getMultiplier(),
score.getBadCuts(),
score.getMissedNotes(),
score.getMaxCombo(),
score.isFullCombo(),
score.getHmd(),
DateUtils.getDateFromString(score.getTimeSet()),
score.isHasReplay(),
score.getDeviceHmd(),
score.getDeviceControllerLeft(),
score.getDeviceControllerRight(),
new ArrayList<>(),
playerId,
leaderboard.getId(),
Difficulty.fromId(leaderboard.getDifficulty().getDifficulty())
);
}
@AllArgsConstructor @Getter
public enum Difficulty {
EASY(1),
NORMAL(3),
HARD(5),
EXPERT(7),
EXPERT_PLUS(9);
/**
* The ScoreSaber difficulty id.
*/
private final int id;
/**
* Gets the difficulty from the given id.
*
* @param id The id.
* @return The difficulty.
*/
public static Difficulty fromId(int id) {
for (Difficulty difficulty : values()) {
if (difficulty.getId() == id) {
return difficulty;
}
}
return null;
}
}
}

View File

@ -0,0 +1,87 @@
package cc.fascinated.backend.model.token;
import cc.fascinated.backend.model.account.Account;
import lombok.Getter;
@Getter
public class ScoreSaberAccountToken {
/**
* The id for this ScoreSaber account.
*/
private String id;
/**
* The name for this account.
*/
private String name;
/**
* The profile picture for this account.
*/
private String profilePicture;
/**
* The bio for this account.
*/
private String bio;
/**
* The country for this account.
*/
private String country;
/**
* The PP for this account.
*/
private double pp;
/**
* The rank for this account.
*/
private int rank;
/**
* The country rank for this account.
*/
private int countryRank;
/**
* The role for this account.
*/
private String role;
/**
* The badges for this account.
*/
private Account.Badge[] badges;
/**
* The history of the rank for this account.
*/
private String histories;
/**
* The permissions for this account.
*/
private int permissions;
/**
* The banned status for this account.
*/
private boolean banned;
/**
* The inactive status for this account.
*/
private boolean inactive;
/**
* The score stats for this account.
*/
private Account.ScoreStats scoreStats;
/**
* The first time this account was seen.
*/
private String firstSeen;
}

View File

@ -0,0 +1,145 @@
package cc.fascinated.backend.model.token;
import lombok.Getter;
import lombok.ToString;
import java.util.List;
@Getter @ToString
public class ScoreSaberLeaderboardToken {
/**
* The ID of the leaderboard.
*/
private String id;
/**
* The hash of the song.
*/
private String songHash;
/**
* The name of the song.
*/
private String songName;
/**
* The sub name of the song.
*/
private String songSubName;
/**
* The author of the song.
*/
private String songAuthorName;
/**
* The mapper of the song.
*/
private String levelAuthorName;
/**
* The difficulty of the song.
*/
private Difficulty difficulty;
/**
* The maximum score of the song.
*/
private int maxScore;
/**
* The date the leaderboard was created.
*/
private String createdDate;
/**
* The date the song was ranked.
*/
private String rankedDate;
/**
* The date the song was qualified.
*/
private String qualifiedDate;
/**
* The date the song's status was changed to loved.
*/
private String lovedDate;
/**
* Whether this leaderboard is ranked.
*/
private boolean ranked;
/**
* Whether this leaderboard is qualified to be ranked.
*/
private boolean qualified;
/**
* Whether this leaderboard is in a loved state.
*/
private boolean loved;
/**
* The maximum PP for this leaderboard.
*/
private int maxPP;
/**
* The star rating for this leaderboard.
*/
private double stars;
/**
* The amount of plays for this leaderboard.
*/
private int plays;
/**
* The amount of daily plays for this leaderboard.
*/
private int dailyPlays;
/**
* Whether this leaderboard has positive modifiers.
*/
private boolean positiveModifiers;
/**
* The cover image for this leaderboard.
*/
private String coverImage;
/**
* The difficulties for this leaderboard.
*/
private List<Difficulty> difficulties;
/**
* The difficulty of the leaderboard.
*/
@Getter
public static class Difficulty {
/**
* The leaderboard ID.
*/
private int leaderboardId;
/**
* The difficulty of the leaderboard.
*/
private int difficulty;
/**
* The game mode of the leaderboard.
*/
private String gameMode;
/**
* The difficulty raw of the leaderboard.
*/
private String difficultyRaw;
}
}

View File

@ -0,0 +1,22 @@
package cc.fascinated.backend.model.token;
import lombok.Getter;
import lombok.ToString;
@Getter @ToString
public class ScoreSaberPageMetadataToken {
/**
* The total amount of scores.
*/
private int total;
/**
* The current page.
*/
private int page;
/**
* The amount of scores per page.
*/
private int itemsPerPage;
}

View File

@ -0,0 +1,17 @@
package cc.fascinated.backend.model.token;
import lombok.Getter;
import lombok.ToString;
@Getter @ToString
public class ScoreSaberPlayerScoreToken {
/**
* The score that was set.
*/
private ScoreSaberScoreToken score;
/**
* The leaderboard that the score was set on.
*/
private ScoreSaberLeaderboardToken leaderboard;
}

View File

@ -0,0 +1,135 @@
package cc.fascinated.backend.model.token;
import lombok.Getter;
import lombok.ToString;
@Getter @ToString
public class ScoreSaberScoreToken {
/**
* The id for this score.
*/
private String id;
/**
* The player info for this score.
*/
private LeaderboardPlayerInfo leaderboardPlayerInfo;
/**
* The rank of this score.
*/
private int rank;
/**
* The base score for this score.
*/
private int baseScore;
/**
* The modified score for this score.
*/
private int modifiedScore;
/**
* The PP for this score.
*/
private double pp;
/**
* The weight for this score.
*/
private int weight;
/**
* The modifiers for this score.
*/
private String modifiers;
/**
* The multiplier for this score.
*/
private int multiplier;
/**
* How many bad cuts this score has.
*/
private int badCuts;
/**
* How many misses this score has.
*/
private int missedNotes;
/**
* The maximum combo for this score.
*/
private int maxCombo;
/**
* Whether this score was a full combo.
*/
private boolean fullCombo;
/**
* The HMD that was used to set this score.
*/
private int hmd;
/**
* The time set for this score.
*/
private String timeSet;
/**
* Whether this score has a replay.
*/
private boolean hasReplay;
/**
* The full HMD name that was used to set this score.
*/
private String deviceHmd;
/**
* The controller that was used on the left hand.
*/
private String deviceControllerLeft;
/**
* The controller that was used on the right hand.
*/
private String deviceControllerRight;
@Getter
public class LeaderboardPlayerInfo {
/**
* The ID of the player.
*/
private String id;
/**
* The name of the player.
*/
private String name;
/**
* The profile picture of the player.
*/
private String profilePicture;
/**
* The country of the player.
*/
private String country;
/**
* The permissions for the player.
*/
private int permissions;
/**
* The role for the player.
*/
private String role;
}
}

View File

@ -0,0 +1,19 @@
package cc.fascinated.backend.model.token;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter @Setter
@ToString
public class ScoreSaberScoresPageToken {
/**
* The scores on this page.
*/
private ScoreSaberPlayerScoreToken[] playerScores;
/**
* The metadata for this page.
*/
private ScoreSaberPageMetadataToken metadata;
}

View File

@ -0,0 +1,9 @@
package cc.fascinated.backend.repository;
import cc.fascinated.backend.model.account.Account;
import org.springframework.data.mongodb.repository.MongoRepository;
/**
* A repository for {@link Account}s.
*/
public interface AccountRepository extends MongoRepository<Account, String> { }

View File

@ -0,0 +1,9 @@
package cc.fascinated.backend.repository;
import cc.fascinated.backend.model.leaderboard.Leaderboard;
import org.springframework.data.mongodb.repository.MongoRepository;
/**
* A repository for {@link Leaderboard}s.
*/
public interface LeaderboardRepository extends MongoRepository<Leaderboard, String> { }

View File

@ -0,0 +1,31 @@
package cc.fascinated.backend.repository;
import cc.fascinated.backend.model.score.Score;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import java.util.List;
/**
* A repository for {@link Score}s.
*/
public interface ScoreRepository extends MongoRepository<Score, String> {
/**
* Gets the scores for an account.
*
* @param accountId The id of the account.
* @return The scores for the account.
*/
@Query("{ 'accountId' : ?0 }")
List<Score> getScoresForAccount(String accountId);
/**
* Gets the scores sorted by the newest for an account.
*
* @param accountId The id of the account.
* @return The scores.
*/
@Query(value = "{ 'accountId' : ?0 }", sort = "{ 'timeSet' : -1 }")
List<Score> getScoresSortedByNewest(String accountId);
}

View File

@ -0,0 +1,99 @@
package cc.fascinated.backend.service;
import cc.fascinated.backend.common.Timer;
import cc.fascinated.backend.model.account.Account;
import cc.fascinated.backend.model.token.ScoreSaberAccountToken;
import cc.fascinated.backend.repository.AccountRepository;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@Service @Log4j2(topic = "Account Service")
public class AccountService {
/**
* How often the account should be updated.
*/
private static final long UPDATE_INTERVAL = TimeUnit.HOURS.toMillis(1);
/**
* The {@link AccountRepository} instance.
*/
private final AccountRepository accountRepository;
/**
* The {@link ScoreSaberService} instance.
*/
private final ScoreSaberService scoreSaberService;
@Autowired
public AccountService(AccountRepository accountRepository, ScoreSaberService scoreSaberService) {
this.accountRepository = accountRepository;
this.scoreSaberService = scoreSaberService;
// todo: Schedule the account update task.
Timer.scheduleRepeating(() -> {
List<Account> accounts = accountRepository.findAll();
log.info("Updating accounts.");
for (Account account : accounts) {
updateAccount(account);
}
log.info("Updated {} accounts.", accounts.size());
}, 0, UPDATE_INTERVAL);
}
/**
* Gets the ScoreSaber account.
* <p>
* If the account is not found in the database,
* it will be fetched from the ScoreSaber API,
* fetch all the scores for the account then
* save the account to the database.
* </p>
*
* @param id The id of the account.
* @return The account.
*/
public Account getAccount(String id) {
log.info("Fetching account '{}'.", id);
Optional<Account> optionalAccount = accountRepository.findById(id);
if (optionalAccount.isEmpty()) {
log.info("Account '{}' not found in the database. Fetching from ScoreSaber API.", id);
Account account = Account.fromToken(scoreSaberService.getAccount(id));
updateAccount(account); // Fetch the scores for the account.
accountRepository.save(account); // Save the account to the database.
return account;
}
log.info("Account '{}' found in the database.", id);
return optionalAccount.get();
}
/**
* Fetches the account from the ScoreSaber API
* and saves it to the database.
*
* @param account The account.
*/
public void updateAccount(Account account) {
String id = account.getId();
// Fetch the account from the ScoreSaber API.
ScoreSaberAccountToken accountToken = scoreSaberService.getAccount(id); // Fetch the account from the ScoreSaber API.
if (accountToken == null) {
log.warn("Account '{}' not found in the ScoreSaber API.", id);
return;
}
// Update the account with the new token.
Account updatedAccount = Account.fromToken(accountToken);
account = accountRepository.save(updatedAccount); // Save the account to the database.
// Fetch the scores for the account.
scoreSaberService.updateScores(account);
}
}

View File

@ -0,0 +1,318 @@
package cc.fascinated.backend.service;
import cc.fascinated.backend.Main;
import cc.fascinated.backend.common.DateUtils;
import cc.fascinated.backend.common.Timer;
import cc.fascinated.backend.common.WebRequest;
import cc.fascinated.backend.exception.impl.RateLimitException;
import cc.fascinated.backend.exception.impl.ResourceNotFoundException;
import cc.fascinated.backend.model.account.Account;
import cc.fascinated.backend.model.leaderboard.Leaderboard;
import cc.fascinated.backend.model.score.Score;
import cc.fascinated.backend.model.token.*;
import cc.fascinated.backend.repository.AccountRepository;
import cc.fascinated.backend.repository.LeaderboardRepository;
import cc.fascinated.backend.repository.ScoreRepository;
import com.google.gson.JsonObject;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@Service @Log4j2(topic = "ScoreSaber Service")
public class ScoreSaberService extends TextWebSocketHandler {
private static final long LEADERBOARD_UPDATE_INTERVAL = TimeUnit.HOURS.toMillis(24);
private static final String SCORESABER_API = "https://scoresaber.com/api/";
private static final String GET_PLAYER_ENDPOINT = SCORESABER_API + "player/%s/full";
private static final String GET_PLAYER_SCORES_ENDPOINT = SCORESABER_API + "player/%s/scores?limit=100&sort=%s&page=%s&withMetadata=true";
private static final String GET_LEADERBOARD_ENDPOINT = SCORESABER_API + "leaderboard/by-id/%s/info";
/**
* The {@link ScoreRepository} instance.
*/
private final ScoreRepository scoreRepository;
/**
* The {@link LeaderboardRepository} instance.
*/
private final LeaderboardRepository leaderboardRepository;
/**
* The {@link AccountRepository} instance.
*/
private final AccountRepository accountRepository;
@SneakyThrows @Autowired
public ScoreSaberService(ScoreRepository scoreRepository, LeaderboardRepository leaderboardRepository, AccountRepository accountRepository) {
this.scoreRepository = scoreRepository;
this.leaderboardRepository = leaderboardRepository;
this.accountRepository = accountRepository;
connectWebSocket(); // Connect to the ScoreSaber WebSocket.
Timer.scheduleRepeating(this::updateLeaderboards, LEADERBOARD_UPDATE_INTERVAL, LEADERBOARD_UPDATE_INTERVAL);
}
/**
* Gets the account from the ScoreSaber API.
*
* @param id The id of the account.
* @return The account.
* @throws ResourceNotFoundException If the account is not found.
* @throws RateLimitException If the ScoreSaber rate limit is reached.
*/
public ScoreSaberAccountToken getAccount(String id) {
ScoreSaberAccountToken account = WebRequest.getAsEntity(String.format(GET_PLAYER_ENDPOINT, id), ScoreSaberAccountToken.class);
if (account == null) { // Check if the account doesn't exist.
throw new ResourceNotFoundException("Account with id '%s' not found.".formatted(id));
}
return account;
}
/**
* Gets the scores for the account.
*
* @param account The account.
* @param page The page to get the scores from.
* @return The scores.
*/
public ScoreSaberScoresPageToken getPageScores(Account account, int page) {
log.info("Fetching scores for account '{}' from page {}.", account.getId(), page);
ScoreSaberScoresPageToken pageToken = WebRequest.getAsEntity(String.format(GET_PLAYER_SCORES_ENDPOINT, account.getId(), "recent", page), ScoreSaberScoresPageToken.class);
if (pageToken == null) { // Check if the page doesn't exist.
return null;
}
// Sort the scores by newest time set.
pageToken.setPlayerScores(Arrays.stream(pageToken.getPlayerScores())
.sorted((a, b) -> DateUtils.getDateFromString(b.getScore().getTimeSet()).compareTo(DateUtils.getDateFromString(a.getScore().getTimeSet())))
.toArray(ScoreSaberPlayerScoreToken[]::new));
return pageToken;
}
/**
* Gets the scores for the account.
*
* @param account The account.
* @return The scores.
*/
public List<ScoreSaberScoresPageToken> getScores(Account account) {
List<ScoreSaberScoresPageToken> scores = new ArrayList<>(List.of(getPageScores(account, 1)));
ScoreSaberPageMetadataToken metadata = scores.get(0).getMetadata();
int totalPages = (int) Math.ceil((double) metadata.getTotal() / metadata.getItemsPerPage());
log.info("Fetching {} pages of scores for account '{}'.", totalPages, account.getId());
for (int i = 2; i <= totalPages; i++) {
scores.add(getPageScores(account, i));
}
return scores;
}
/**
* Fetch the scores until the specified score id.
*
* @param account The account.
* @param scoreUntil The score to fetch until.
* @return The scores.
*/
public List<ScoreSaberPlayerScoreToken> getScoreUntil(Account account, Score scoreUntil) {
List<ScoreSaberPlayerScoreToken> scores = new ArrayList<>();
int page = 1;
do {
ScoreSaberScoresPageToken pageToken = getPageScores(account, page);
for (ScoreSaberPlayerScoreToken score : pageToken.getPlayerScores()) {
// If the score isn't the same as the scoreUntil, add it to the list.
if (!DateUtils.getDateFromString(score.getScore().getTimeSet()).equals(scoreUntil.getTimeSet())) {
scores.add(score);
}
if (score.getScore().getId().equals(scoreUntil.getId())) {
// If the current score matches the specified scoreUntil, stop fetching.
return scores;
}
}
page++;
} while (true);
}
/**
* Fetches the scores for the account.
*
* @param account The account.
*/
public void updateScores(Account account) {
String id = account.getId();
// Fetch the scores for the account.
List<Score> scores = scoreRepository.getScoresForAccount(id);
if (scores.isEmpty()) {
log.warn("Account '{}' has no scores, fetching them.", id);
List<ScoreSaberScoresPageToken> scoresPageTokens = this.getScores(account);
List<Score> newScores = new ArrayList<>();
List<Leaderboard> leaderboardToSave = new ArrayList<>();
for (ScoreSaberScoresPageToken page : scoresPageTokens) {
for (ScoreSaberPlayerScoreToken score : page.getPlayerScores()) {
newScores.add(Score.fromToken(id, score));
leaderboardToSave.add(Leaderboard.fromToken(score.getLeaderboard()));
}
}
// Save the leaderboards if they are missing.
for (Leaderboard leaderboard : leaderboardToSave) {
if (leaderboardRepository.findById(leaderboard.getId()).isEmpty()) {
leaderboardRepository.save(leaderboard);
}
}
scoreRepository.saveAll(newScores); // Save the player's scores.
log.info("Found {} scores for account '{}'.", newScores.size(), id);
return;
}
long start = System.currentTimeMillis();
log.info("Fetching new scores for account '{}'.", id);
Score latestScore = scoreRepository.getScoresSortedByNewest(id).get(0);
List<ScoreSaberPlayerScoreToken> newScores = this.getScoreUntil(account, latestScore);
if (newScores.isEmpty()) {
log.info("No new scores found for account '{}'.", id);
return;
}
int newScoreCount = 0;
for (ScoreSaberPlayerScoreToken newScore : newScores) {
if (saveScore(account, newScore)) {
newScoreCount++;
}
}
log.info("Found {} new scores for account '{}'. (took: {}ms)", newScoreCount, id, System.currentTimeMillis() - start);
}
/**
* Saves the score for the account.
*
* @param account The account.
* @param score The score to save.
* @return Whether the score was saved.
*/
private boolean saveScore(Account account, ScoreSaberPlayerScoreToken score) {
boolean didSave = false;
Leaderboard newScoreLeaderboard = Leaderboard.fromToken(score.getLeaderboard());
Score oldScore = scoreRepository.findById(score.getScore().getId()).orElse(null);
// The score has an old score.
if (oldScore != null) {
Leaderboard oldScoreLeaderboard = leaderboardRepository.findById(oldScore.getLeaderboardId()).orElse(null);
if (oldScoreLeaderboard != null && oldScoreLeaderboard.getId().equals(newScoreLeaderboard.getId())) {
// If it matches, add the new score and retain information about the old score.
Score scoreSet = Score.fromToken(account.getId(), score);
oldScore.setPreviousScores(null); // We don't want nested previous scores.
scoreSet.getPreviousScores().add(oldScore);
scoreRepository.delete(oldScore); // Delete the old score.
scoreRepository.save(scoreSet); // Save the new score.
didSave = true;
}
} else {
// The score is new
scoreRepository.save(Score.fromToken(account.getId(), score)); // Save the new score.
didSave = true;
}
// Check if the leaderboard doesn't already exist.
if (leaderboardRepository.findById(newScoreLeaderboard.getId()).isEmpty()) {
leaderboardRepository.save(newScoreLeaderboard); // Save the leaderboard.
}
return didSave;
}
/**
* Updates the leaderboards.
*/
private void updateLeaderboards() {
log.info("Updating leaderboards.");
List<String> leaderboardIds = new ArrayList<>();
// Get all the unique leaderboard ids.
for (Score score : scoreRepository.findAll()) {
leaderboardIds.add(score.getLeaderboardId());
}
// Fetch the leaderboards.
for (String leaderboardId : leaderboardIds) {
ScoreSaberLeaderboardToken leaderboard = WebRequest.getAsEntity(String.format(GET_LEADERBOARD_ENDPOINT, leaderboardId),
ScoreSaberLeaderboardToken.class);
// No leaderboard found.
if (leaderboard == null) {
log.warn("Leaderboard '{}' not found.", leaderboardId);
continue;
}
// Save the leaderboard.
leaderboardRepository.save(Leaderboard.fromToken(leaderboard));
}
log.info("Updated {} leaderboards.", leaderboardIds.size());
}
/**
* Connects to the ScoreSaber WebSocket.
*/
@SneakyThrows
private void connectWebSocket() {
new StandardWebSocketClient().execute(this, "wss://scoresaber.com/ws").get();
}
@Override @SneakyThrows
protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) {
// Ignore the connection message.
if (message.getPayload().equals("Connected to the ScoreSaber WSS")) {
return;
}
try {
JsonObject json = Main.GSON.fromJson(message.getPayload(), JsonObject.class);
String command = json.get("commandName").getAsString();
JsonObject data = json.get("commandData").getAsJsonObject();
if (command.equals("score")) {
ScoreSaberPlayerScoreToken score = Main.GSON.fromJson(data, ScoreSaberPlayerScoreToken.class);
ScoreSaberScoreToken.LeaderboardPlayerInfo playerInfo = score.getScore().getLeaderboardPlayerInfo();
// Fetch the account.
Optional<Account> account = accountRepository.findById(playerInfo.getId());
if (account.isEmpty()) {
// We don't track this account, so ignore it.
return;
}
// Save the score.
saveScore(account.get(), score);
log.info("Saved websocket score '{}' for account '{}'.", score.getScore().getId(), playerInfo.getName());
}
} catch (Exception ex) {
log.error("An error occurred while handling the message.", ex);
}
}
@Override
public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) {
log.info("Disconnected from the ScoreSaber WSS.");
connectWebSocket(); // Reconnect to the WebSocket.
}
}

View File

@ -0,0 +1,35 @@
package cc.fascinated.backend;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class AccountControllerTests {
@Autowired
private MockMvc mockMvc;
@Test
public void ensureAccountRetrieveSuccess() throws Exception {
mockMvc.perform(get("/account/76561198449412074")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());
}
@Test
public void ensureAccountRetrieveFailure() throws Exception {
mockMvc.perform(get("/account/432747328774289348237984723984")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
}