commit 4582be43b38004421db67e7584fff3398c5418ab Author: Liam Date: Thu Apr 25 05:05:10 2024 +0100 initial testing diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..421da1c --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,51 @@ +name: Deploy App + +on: + push: + branches: ["master"] + paths-ignore: + - .gitignore + - README.md + - LICENSE + - docker-compose.yml + +jobs: + docker: + strategy: + matrix: + arch: ["ubuntu-latest"] + git-version: ["2.44.0"] + java-version: ["17"] + maven-version: ["3.8.5"] + runs-on: ${{ matrix.arch }} + + # Steps to run + steps: + # Checkout the repo + - name: Checkout + uses: actions/checkout@v4 + + # Setup Java and Maven + - name: Set up JDK and Maven + uses: s4u/setup-maven-action@v1.12.0 + with: + java-version: ${{ matrix.java-version }} + distribution: "zulu" + maven-version: ${{ matrix.maven-version }} + + # Run JUnit Tests + - name: Run Tests + run: mvn --batch-mode test -q + + # Re-checkout to reset the FS before deploying to Dokku + - name: Checkout - Reset FS + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Deploy to Dokku + - name: Push to dokku + uses: dokku/github-action@master + with: + git_remote_url: "ssh://dokku@10.0.50.175:22/paste-backend" + ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77440ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +### ME template +*.class +*.log +*.ctxt +.mtj.tmp/ +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +hs_err_pid* +replay_pid* +.idea +cmake-build-*/ +.idea/**/mongoSettings.xml +*.iws +out/ +build/ +work/ +.idea_modules/ +atlassian-ide-plugin.xml +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +git.properties +pom.xml.versionsBackup +application.yml +target/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1928a94 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Stage 1: Build the application +FROM maven:3.9.6-eclipse-temurin-17-alpine AS builder + +# Set the working directory +WORKDIR /home/container + +# Copy the current directory contents into the container at /home/container +COPY . . + +# Build the jar +RUN mvn package -q -Dmaven.test.skip -DskipTests -T2C + +# Stage 2: Create the final lightweight image +FROM eclipse-temurin:17.0.11_9-jre-focal + +# Set the working directory +WORKDIR /home/container + +# Copy the built jar file from the builder stage +COPY --from=builder /home/container/target/Paste-Backend.jar . + +# Make port 3000 available to the world outside this container +EXPOSE 3000 +ENV PORT=3000 + +# Run the jar file +CMD java -jar ScoreSaberUtils-Backend.jar -Djava.awt.headless=true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c52daa5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2024 Liam (Fascinated) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..c3d2e5f --- /dev/null +++ b/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + cc.fascinated + ScoreSaberUtils + 1.0-SNAPSHOT + + + 17 + 17 + UTF-8 + + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + + + ${project.artifactId} + + + + org.springframework.boot + spring-boot-maven-plugin + + + build-info + + build-info + + + + ${project.description} + + + + + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + + + org.projectlombok + lombok + 1.18.32 + provided + + + org.apache.commons + commons-lang3 + 3.14.0 + compile + + + org.apache.httpcomponents.client5 + httpclient5 + 5.3.1 + + + com.google.code.gson + gson + 2.8.8 + + + + + org.springframework.boot + spring-boot-starter-websocket + + + + + org.springframework.boot + spring-boot-starter-test + test + + + de.flapdoodle.embed + de.flapdoodle.embed.mongo.spring3x + 4.12.6 + test + + + + \ No newline at end of file diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/src/main/java/cc/fascinated/backend/Main.java b/src/main/java/cc/fascinated/backend/Main.java new file mode 100644 index 0000000..e99d260 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/Main.java @@ -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 + } +} \ No newline at end of file diff --git a/src/main/java/cc/fascinated/backend/common/DateUtils.java b/src/main/java/cc/fascinated/backend/common/DateUtils.java new file mode 100644 index 0000000..751f6ed --- /dev/null +++ b/src/main/java/cc/fascinated/backend/common/DateUtils.java @@ -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))); + } +} diff --git a/src/main/java/cc/fascinated/backend/common/IPUtils.java b/src/main/java/cc/fascinated/backend/common/IPUtils.java new file mode 100644 index 0000000..303c45d --- /dev/null +++ b/src/main/java/cc/fascinated/backend/common/IPUtils.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/cc/fascinated/backend/common/Timer.java b/src/main/java/cc/fascinated/backend/common/Timer.java new file mode 100644 index 0000000..3f9ea18 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/common/Timer.java @@ -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); + } +} diff --git a/src/main/java/cc/fascinated/backend/common/WebRequest.java b/src/main/java/cc/fascinated/backend/common/WebRequest.java new file mode 100644 index 0000000..320b5b4 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/common/WebRequest.java @@ -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 the type of the response + */ + public static T getAsEntity(String url, Class clazz) throws RateLimitException { + ResponseEntity 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); + } +} diff --git a/src/main/java/cc/fascinated/backend/config/Config.java b/src/main/java/cc/fascinated/backend/config/Config.java new file mode 100644 index 0000000..2789a77 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/config/Config.java @@ -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 + } + }; + } +} diff --git a/src/main/java/cc/fascinated/backend/controller/AccountController.java b/src/main/java/cc/fascinated/backend/controller/AccountController.java new file mode 100644 index 0000000..484e2bb --- /dev/null +++ b/src/main/java/cc/fascinated/backend/controller/AccountController.java @@ -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); + } +} diff --git a/src/main/java/cc/fascinated/backend/exception/ExceptionControllerAdvice.java b/src/main/java/cc/fascinated/backend/exception/ExceptionControllerAdvice.java new file mode 100644 index 0000000..5d9cc6e --- /dev/null +++ b/src/main/java/cc/fascinated/backend/exception/ExceptionControllerAdvice.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/cc/fascinated/backend/exception/impl/BadRequestException.java b/src/main/java/cc/fascinated/backend/exception/impl/BadRequestException.java new file mode 100644 index 0000000..e9e0343 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/exception/impl/BadRequestException.java @@ -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 { } diff --git a/src/main/java/cc/fascinated/backend/exception/impl/RateLimitException.java b/src/main/java/cc/fascinated/backend/exception/impl/RateLimitException.java new file mode 100644 index 0000000..6c917b9 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/exception/impl/RateLimitException.java @@ -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); + } +} diff --git a/src/main/java/cc/fascinated/backend/exception/impl/ResourceNotFoundException.java b/src/main/java/cc/fascinated/backend/exception/impl/ResourceNotFoundException.java new file mode 100644 index 0000000..09a19a1 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/exception/impl/ResourceNotFoundException.java @@ -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 { } diff --git a/src/main/java/cc/fascinated/backend/log/TransactionLogger.java b/src/main/java/cc/fascinated/backend/log/TransactionLogger.java new file mode 100644 index 0000000..08297ec --- /dev/null +++ b/src/main/java/cc/fascinated/backend/log/TransactionLogger.java @@ -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 { + + @Override + public Object beforeBodyWrite(Object body, @NonNull MethodParameter returnType, @NonNull MediaType selectedContentType, + @NonNull Class> 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 params = new HashMap<>(); + for (Entry 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> converterType) { + return true; + } +} \ No newline at end of file diff --git a/src/main/java/cc/fascinated/backend/model/account/Account.java b/src/main/java/cc/fascinated/backend/model/account/Account.java new file mode 100644 index 0000000..eafa8c8 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/model/account/Account.java @@ -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; + } +} diff --git a/src/main/java/cc/fascinated/backend/model/leaderboard/Leaderboard.java b/src/main/java/cc/fascinated/backend/model/leaderboard/Leaderboard.java new file mode 100644 index 0000000..2f5e050 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/model/leaderboard/Leaderboard.java @@ -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() + ); + } + } +} diff --git a/src/main/java/cc/fascinated/backend/model/response/ErrorResponse.java b/src/main/java/cc/fascinated/backend/model/response/ErrorResponse.java new file mode 100644 index 0000000..8047fc1 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/model/response/ErrorResponse.java @@ -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(); + } +} \ No newline at end of file diff --git a/src/main/java/cc/fascinated/backend/model/score/Score.java b/src/main/java/cc/fascinated/backend/model/score/Score.java new file mode 100644 index 0000000..1a1c37d --- /dev/null +++ b/src/main/java/cc/fascinated/backend/model/score/Score.java @@ -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 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 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 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; + } + } +} + diff --git a/src/main/java/cc/fascinated/backend/model/token/ScoreSaberAccountToken.java b/src/main/java/cc/fascinated/backend/model/token/ScoreSaberAccountToken.java new file mode 100644 index 0000000..421e58b --- /dev/null +++ b/src/main/java/cc/fascinated/backend/model/token/ScoreSaberAccountToken.java @@ -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; +} diff --git a/src/main/java/cc/fascinated/backend/model/token/ScoreSaberLeaderboardToken.java b/src/main/java/cc/fascinated/backend/model/token/ScoreSaberLeaderboardToken.java new file mode 100644 index 0000000..870cc10 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/model/token/ScoreSaberLeaderboardToken.java @@ -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 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; + } +} diff --git a/src/main/java/cc/fascinated/backend/model/token/ScoreSaberPageMetadataToken.java b/src/main/java/cc/fascinated/backend/model/token/ScoreSaberPageMetadataToken.java new file mode 100644 index 0000000..cab0efd --- /dev/null +++ b/src/main/java/cc/fascinated/backend/model/token/ScoreSaberPageMetadataToken.java @@ -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; +} diff --git a/src/main/java/cc/fascinated/backend/model/token/ScoreSaberPlayerScoreToken.java b/src/main/java/cc/fascinated/backend/model/token/ScoreSaberPlayerScoreToken.java new file mode 100644 index 0000000..e75bd10 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/model/token/ScoreSaberPlayerScoreToken.java @@ -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; +} diff --git a/src/main/java/cc/fascinated/backend/model/token/ScoreSaberScoreToken.java b/src/main/java/cc/fascinated/backend/model/token/ScoreSaberScoreToken.java new file mode 100644 index 0000000..ac03356 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/model/token/ScoreSaberScoreToken.java @@ -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; + } +} diff --git a/src/main/java/cc/fascinated/backend/model/token/ScoreSaberScoresPageToken.java b/src/main/java/cc/fascinated/backend/model/token/ScoreSaberScoresPageToken.java new file mode 100644 index 0000000..8e47994 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/model/token/ScoreSaberScoresPageToken.java @@ -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; +} diff --git a/src/main/java/cc/fascinated/backend/repository/AccountRepository.java b/src/main/java/cc/fascinated/backend/repository/AccountRepository.java new file mode 100644 index 0000000..f24639a --- /dev/null +++ b/src/main/java/cc/fascinated/backend/repository/AccountRepository.java @@ -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 { } diff --git a/src/main/java/cc/fascinated/backend/repository/LeaderboardRepository.java b/src/main/java/cc/fascinated/backend/repository/LeaderboardRepository.java new file mode 100644 index 0000000..ad7692c --- /dev/null +++ b/src/main/java/cc/fascinated/backend/repository/LeaderboardRepository.java @@ -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 { } diff --git a/src/main/java/cc/fascinated/backend/repository/ScoreRepository.java b/src/main/java/cc/fascinated/backend/repository/ScoreRepository.java new file mode 100644 index 0000000..ac96bf8 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/repository/ScoreRepository.java @@ -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 { + + /** + * Gets the scores for an account. + * + * @param accountId The id of the account. + * @return The scores for the account. + */ + @Query("{ 'accountId' : ?0 }") + List 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 getScoresSortedByNewest(String accountId); +} diff --git a/src/main/java/cc/fascinated/backend/service/AccountService.java b/src/main/java/cc/fascinated/backend/service/AccountService.java new file mode 100644 index 0000000..5209880 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/service/AccountService.java @@ -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 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. + *

+ * 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. + *

+ * + * @param id The id of the account. + * @return The account. + */ + public Account getAccount(String id) { + log.info("Fetching account '{}'.", id); + Optional 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); + } +} diff --git a/src/main/java/cc/fascinated/backend/service/ScoreSaberService.java b/src/main/java/cc/fascinated/backend/service/ScoreSaberService.java new file mode 100644 index 0000000..ebc9de8 --- /dev/null +++ b/src/main/java/cc/fascinated/backend/service/ScoreSaberService.java @@ -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 getScores(Account account) { + List 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 getScoreUntil(Account account, Score scoreUntil) { + List 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 scores = scoreRepository.getScoresForAccount(id); + if (scores.isEmpty()) { + log.warn("Account '{}' has no scores, fetching them.", id); + + List scoresPageTokens = this.getScores(account); + List newScores = new ArrayList<>(); + List 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 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 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 = 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. + } +} diff --git a/src/test/java/cc/fascinated/backend/AccountControllerTests.java b/src/test/java/cc/fascinated/backend/AccountControllerTests.java new file mode 100644 index 0000000..9bd0b10 --- /dev/null +++ b/src/test/java/cc/fascinated/backend/AccountControllerTests.java @@ -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()); + } +}